Browse Source

Merge branch 'master' into multipleTextFixes

pull/10936/head
Benedikt Stebner 3 years ago
committed by GitHub
parent
commit
e76e85fdaf
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      samples/VirtualizationDemo/App.axaml
  2. 20
      samples/VirtualizationDemo/App.axaml.cs
  3. 7
      samples/VirtualizationDemo/App.xaml
  4. 21
      samples/VirtualizationDemo/App.xaml.cs
  5. 190
      samples/VirtualizationDemo/Assets/chat.json
  6. 20
      samples/VirtualizationDemo/MainWindow.axaml
  7. 15
      samples/VirtualizationDemo/MainWindow.axaml.cs
  8. 64
      samples/VirtualizationDemo/MainWindow.xaml
  9. 22
      samples/VirtualizationDemo/MainWindow.xaml.cs
  10. 23
      samples/VirtualizationDemo/Models/Chat.cs
  11. 19
      samples/VirtualizationDemo/Program.cs
  12. 17
      samples/VirtualizationDemo/ViewModels/ChatPageViewModel.cs
  13. 21
      samples/VirtualizationDemo/ViewModels/ExpanderItemViewModel.cs
  14. 17
      samples/VirtualizationDemo/ViewModels/ExpanderPageViewModel.cs
  15. 26
      samples/VirtualizationDemo/ViewModels/ItemViewModel.cs
  16. 164
      samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs
  17. 17
      samples/VirtualizationDemo/ViewModels/PlaygroundItemViewModel.cs
  18. 95
      samples/VirtualizationDemo/ViewModels/PlaygroundPageViewModel.cs
  19. 39
      samples/VirtualizationDemo/Views/ChatPageView.axaml
  20. 11
      samples/VirtualizationDemo/Views/ChatPageView.axaml.cs
  21. 18
      samples/VirtualizationDemo/Views/ExpanderPageView.axaml
  22. 13
      samples/VirtualizationDemo/Views/ExpanderPageView.axaml.cs
  23. 66
      samples/VirtualizationDemo/Views/PlaygroundPageView.axaml
  24. 44
      samples/VirtualizationDemo/Views/PlaygroundPageView.axaml.cs
  25. 21
      samples/VirtualizationDemo/VirtualizationDemo.csproj
  26. 26
      src/Avalonia.Base/Controls/ResourceDictionary.cs
  27. 4
      src/Avalonia.Base/Media/DrawingContext.cs
  28. 56
      src/Avalonia.Base/Media/FontManager.cs
  29. 206
      src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs
  30. 259
      src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
  31. 17
      src/Avalonia.Base/Media/Fonts/IFontCollection.cs
  32. 66
      src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs
  33. 4
      src/Avalonia.Base/Platform/IFontManagerImpl.cs
  34. 13
      src/Avalonia.Controls/ISelectable.cs
  35. 48
      src/Avalonia.Controls/ItemsControl.cs
  36. 3
      src/Avalonia.Controls/ListBoxItem.cs
  37. 2
      src/Avalonia.Controls/MenuItem.cs
  38. 4
      src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs
  39. 1
      src/Avalonia.Controls/Primitives/PopupRoot.cs
  40. 123
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  41. 9
      src/Avalonia.Controls/Primitives/Thumb.cs
  42. 8
      src/Avalonia.Controls/Slider.cs
  43. 2
      src/Avalonia.Controls/TabItem.cs
  44. 16
      src/Avalonia.Controls/TopLevel.cs
  45. 76
      src/Avalonia.Controls/TreeView.cs
  46. 7
      src/Avalonia.Controls/TreeViewItem.cs
  47. 499
      src/Avalonia.Controls/Utils/RealizedStackElements.cs
  48. 666
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  49. 2
      src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  50. 3
      src/Avalonia.Headless/HeadlessPlatformStubs.cs
  51. 8
      src/Avalonia.X11/Glx/GlxContext.cs
  52. 22
      src/Avalonia.X11/Glx/GlxDisplay.cs
  53. 5
      src/Avalonia.X11/X11Platform.cs
  54. 2
      src/Avalonia.X11/X11Window.Ime.cs
  55. 91
      src/Avalonia.X11/X11Window.cs
  56. 41
      src/Skia/Avalonia.Skia/FontManagerImpl.cs
  57. 3
      src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs
  58. 16
      src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
  59. 18
      src/Windows/Avalonia.Win32/WindowImpl.cs
  60. 2
      tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs
  61. 918
      tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs
  62. 475
      tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs
  63. 7
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  64. 1
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  65. 1579
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs
  66. 249
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  67. 213
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
  68. 2
      tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs
  69. 54
      tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ResourceDictionaryTests.cs
  70. 51
      tests/Avalonia.RenderTests/Media/BoxShadowTests.cs
  71. 159
      tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs
  72. 24
      tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs
  73. 4
      tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs
  74. 18
      tests/Avalonia.UnitTests/MockFontManagerImpl.cs
  75. BIN
      tests/TestFiles/Skia/Media/BoxShadow/BoxShadowShouldBeRenderedEvenWithNullBrushAndPen.expected.png

14
samples/VirtualizationDemo/App.axaml

@ -0,0 +1,14 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VirtualizationDemo.App">
<Application.Styles>
<FluentTheme/>
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ControlSamples/HamburgerMenu/HamburgerMenu.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

20
samples/VirtualizationDemo/App.axaml.cs

@ -0,0 +1,20 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace VirtualizationDemo;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainWindow();
base.OnFrameworkInitializationCompleted();
}
}

7
samples/VirtualizationDemo/App.xaml

@ -1,7 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VirtualizationDemo.App">
<Application.Styles>
<SimpleTheme />
</Application.Styles>
</Application>

21
samples/VirtualizationDemo/App.xaml.cs

@ -1,21 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace VirtualizationDemo
{
public class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainWindow();
base.OnFrameworkInitializationCompleted();
}
}
}

190
samples/VirtualizationDemo/Assets/chat.json

@ -0,0 +1,190 @@
{
"chat": [
{
"sender": "Alice",
"message": "Hey Bob! How was your weekend?",
"timestamp": "2023-04-01T10:00:00"
},
{
"sender": "Bob",
"message": "It was great, thanks for asking. I went on a camping trip with some friends. How about you?",
"timestamp": "2023-04-01T10:01:00"
},
{
"sender": "Alice",
"message": "My weekend was pretty chill. I just stayed home and caught up on some TV shows.",
"timestamp": "2023-04-01T10:03:00"
},
{
"sender": "Bob",
"message": "That sounds relaxing. What shows did you watch?",
"timestamp": "2023-04-01T10:05:00"
},
{
"sender": "Alice",
"message": "I watched the new season of 'Stranger Things' and started watching 'Ozark'. Have you seen them?",
"timestamp": "2023-04-01T10:07:00"
},
{
"sender": "Bob",
"message": "Yeah, I've seen both of those. They're really good! What do you think of them so far?",
"timestamp": "2023-04-01T10:10:00"
},
{
"sender": "Alice",
"message": "I'm really enjoying 'Stranger Things', but 'Ozark' is a bit darker than I expected. I'm only a few episodes in though, so we'll see how it goes.",
"timestamp": "2023-04-01T10:12:00"
},
{
"sender": "Bob",
"message": "Yeah, 'Ozark' can be intense at times, but it's really well done. Keep watching, it gets even better.",
"timestamp": "2023-04-01T10:15:00"
},
{
"sender": "Alice",
"message": "Thanks for the recommendation, I'll definitely keep watching. So, how's work been for you lately?",
"timestamp": "2023-04-01T10:20:00"
},
{
"sender": "Bob",
"message": "It's been pretty busy, but I'm managing. How about you?",
"timestamp": "2023-04-01T10:22:00"
},
{
"sender": "Alice",
"message": "Same here, things have been pretty hectic. But it keeps us on our toes, right?",
"timestamp": "2023-04-01T10:25:00"
},
{
"sender": "Bob",
"message": "Absolutely. Hey, have you heard about the new project we're starting next week?",
"timestamp": "2023-04-01T10:30:00"
},
{
"sender": "Alice",
"message": "No, I haven't. What's it about?",
"timestamp": "2023-04-01T10:32:00"
},
{
"sender": "Bob",
"message": "It's a big project for a new client, and it's going to require a lot of extra hours from all of us. But the pay is going to be great,so it's definitely worth the extra effort. I'll fill you in on the details later, but for now, let's just enjoy our coffee break, shall we?",
"timestamp": "2023-04-01T10:35:00"
},
{
"sender": "Alice",
"message": "Sounds good to me. I could use a break right about now.",
"timestamp": "2023-04-01T10:40:00"
},
{
"sender": "Bob",
"message": "Me too. So, have you tried the new caf� down the street yet?",
"timestamp": "2023-04-01T10:45:00"
},
{
"sender": "Alice",
"message": "No, I haven't. Is it any good?",
"timestamp": "2023-04-01T10:47:00"
},
{
"sender": "Bob",
"message": "It's really good! They have the best croissants I've ever tasted.",
"timestamp": "2023-04-01T10:50:00"
},
{
"sender": "Alice",
"message": "Hmm, I'll have to try it out sometime. Do they have any vegan options?",
"timestamp": "2023-04-01T10:52:00"
},
{
"sender": "Bob",
"message": "I'm not sure, but I think they do. You should ask them the next time you go there.",
"timestamp": "2023-04-01T10:55:00"
},
{
"sender": "Alice",
"message": "Thanks for the suggestion. I'm always looking for good vegan options around here.",
"timestamp": "2023-04-01T11:00:00"
},
{
"sender": "Bob",
"message": "No problem. So, have you made any plans for the weekend yet?",
"timestamp": "2023-04-01T11:05:00"
},
{
"sender": "Alice",
"message": "Not yet. I was thinking of maybe going for a hike or something. What about you?",
"timestamp": "2023-04-01T11:07:00"
},
{
"sender": "Bob",
"message": "I haven't made any plans either. Maybe we could do something together?",
"timestamp": "2023-04-01T11:10:00"
},
{
"sender": "Alice",
"message": "That sounds like a great idea! Let's plan on it.",
"timestamp": "2023-04-01T11:12:00"
},
{
"sender": "Bob",
"message": "Awesome. I'll check out some hiking trails and let you know which ones look good.",
"timestamp": "2023-04-01T11:15:00"
},
{
"sender": "Alice",
"message": "Sounds good. I can't wait!",
"timestamp": "2023-04-01T11:20:00"
},
{
"sender": "John",
"message": "Hey Lisa, how was your day?",
"timestamp": "2023-04-01T18:00:00"
},
{
"sender": "Lisa",
"message": "It was good, thanks for asking. How about you?",
"timestamp": "2023-04-01T18:05:00"
},
{
"sender": "John",
"message": "Eh, it was alright. Work was pretty busy, but nothing too crazy.",
"timestamp": "2023-04-01T18:10:00"
},
{
"sender": "Lisa",
"message": "Yeah, I know what you mean. My boss has been on my case lately about meeting our deadlines.",
"timestamp": "2023-04-01T18:15:00"
},
{
"sender": "John",
"message": "That sucks. Are you feeling stressed out?",
"timestamp": "2023-04-01T18:20:00"
},
{
"sender": "Lisa",
"message": "A little bit, yeah. But I'm trying to stay positive and focus on getting my work done.",
"timestamp": "2023-04-01T18:25:00"
},
{
"sender": "John",
"message": "That's a good attitude to have. Have you tried doing some meditation or other relaxation techniques?",
"timestamp": "2023-04-01T18:30:00"
},
{
"sender": "Lisa",
"message": "I haven't, but I've been thinking about it. Do you have any suggestions?",
"timestamp": "2023-04-01T18:35:00"
},
{
"sender": "John",
"message": "Sure, I could send you some links to guided meditations that I've found helpful. And there are also some great apps out there that can help you with relaxation.",
"timestamp": "2023-04-01T18:40:00"
},
{
"sender": "Lisa",
"message": "That would be awesome, thanks so much!",
"timestamp": "2023-04-01T18:45:00"
}
]
}

20
samples/VirtualizationDemo/MainWindow.axaml

@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:ControlSamples"
xmlns:vm="using:VirtualizationDemo.ViewModels"
xmlns:views="using:VirtualizationDemo.Views"
x:Class="VirtualizationDemo.MainWindow"
Title="AvaloniaUI Virtualization Demo"
x:DataType="vm:MainWindowViewModel">
<controls:HamburgerMenu>
<TabItem Header="Playground" ScrollViewer.VerticalScrollBarVisibility="Disabled">
<views:PlaygroundPageView DataContext="{Binding Playground}"/>
</TabItem>
<TabItem Header="Chat" ScrollViewer.VerticalScrollBarVisibility="Disabled">
<views:ChatPageView DataContext="{Binding Chat}"/>
</TabItem>
<TabItem Header="Expanders" ScrollViewer.VerticalScrollBarVisibility="Disabled">
<views:ExpanderPageView DataContext="{Binding Expanders}"/>
</TabItem>
</controls:HamburgerMenu>
</Window>

15
samples/VirtualizationDemo/MainWindow.axaml.cs

@ -0,0 +1,15 @@
using Avalonia;
using Avalonia.Controls;
using VirtualizationDemo.ViewModels;
namespace VirtualizationDemo;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.AttachDevTools();
DataContext = new MainWindowViewModel();
}
}

64
samples/VirtualizationDemo/MainWindow.xaml

@ -1,64 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:viewModels="using:VirtualizationDemo.ViewModels"
x:Class="VirtualizationDemo.MainWindow"
Title="AvaloniaUI Virtualization Test"
Width="800"
Height="600"
x:DataType="viewModels:MainWindowViewModel">
<DockPanel LastChildFill="True" Margin="16">
<StackPanel DockPanel.Dock="Right"
Margin="16 0 0 0"
Width="150"
Spacing="4">
<ComboBox ItemsSource="{Binding Orientations}"
SelectedItem="{Binding Orientation}"/>
<TextBox Watermark="Item Count"
UseFloatingWatermark="True"
Text="{Binding ItemCount}"/>
<TextBox Watermark="Extent"
UseFloatingWatermark="True"
Text="{Binding #listBox.Scroll.Extent, Mode=OneWay}"/>
<TextBox Watermark="Offset"
UseFloatingWatermark="True"
Text="{Binding #listBox.Scroll.Offset, Mode=OneWay}"/>
<TextBox Watermark="Viewport"
UseFloatingWatermark="True"
Text="{Binding #listBox.Scroll.Viewport, Mode=OneWay}"/>
<TextBlock>Horiz. ScrollBar</TextBlock>
<ComboBox ItemsSource="{Binding ScrollBarVisibilities}"
SelectedItem="{Binding HorizontalScrollBarVisibility}"/>
<TextBlock>Vert. ScrollBar</TextBlock>
<ComboBox ItemsSource="{Binding ScrollBarVisibilities}"
SelectedItem="{Binding VerticalScrollBarVisibility}"/>
<TextBox Watermark="Item to Create"
UseFloatingWatermark="True"
Text="{Binding NewItemString}"/>
<Button Command="{Binding AddItemCommand}">Add Item</Button>
<Button Command="{Binding RemoveItemCommand}">Remove Item</Button>
<Button Command="{Binding RecreateCommand}">Recreate</Button>
<Button Command="{Binding SelectFirstCommand}">Select First</Button>
<Button Command="{Binding SelectLastCommand}">Select Last</Button>
<Button Command="{Binding RandomizeSize}">Randomize Size</Button>
<Button Command="{Binding ResetSize}">Reset Size</Button>
</StackPanel>
<ListBox Name="listBox"
ItemsSource="{Binding Items}"
Selection="{Binding Selection}"
SelectionMode="Multiple"
ScrollViewer.HorizontalScrollBarVisibility="{Binding HorizontalScrollBarVisibility, Mode=TwoWay}"
ScrollViewer.VerticalScrollBarVisibility="{Binding VerticalScrollBarVisibility, Mode=TwoWay}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="{Binding Orientation}"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Header}" Height="{Binding Height}" TextWrapping="Wrap"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>

22
samples/VirtualizationDemo/MainWindow.xaml.cs

@ -1,22 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using VirtualizationDemo.ViewModels;
namespace VirtualizationDemo
{
public class MainWindow : Window
{
public MainWindow()
{
this.InitializeComponent();
this.AttachDevTools();
DataContext = new MainWindowViewModel();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

23
samples/VirtualizationDemo/Models/Chat.cs

@ -0,0 +1,23 @@
using System;
using System.IO;
using System.Text.Json;
namespace VirtualizationDemo.Models;
public class ChatFile
{
public ChatMessage[]? Chat { get; set; }
public static ChatFile Load(string path)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
using var s = File.OpenRead(path);
return JsonSerializer.Deserialize<ChatFile>(s, options)!;
}
}
public record ChatMessage(string Sender, string Message, DateTimeOffset Timestamp);

19
samples/VirtualizationDemo/Program.cs

@ -1,15 +1,14 @@
using Avalonia;
namespace VirtualizationDemo
namespace VirtualizationDemo;
class Program
{
class Program
{
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
public static int Main(string[] args)
=> BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static int Main(string[] args)
=> BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}

17
samples/VirtualizationDemo/ViewModels/ChatPageViewModel.cs

@ -0,0 +1,17 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using VirtualizationDemo.Models;
namespace VirtualizationDemo.ViewModels;
public class ChatPageViewModel
{
public ChatPageViewModel()
{
var chat = ChatFile.Load(Path.Combine("Assets", "chat.json"));
Messages = new(chat.Chat ?? Array.Empty<ChatMessage>());
}
public ObservableCollection<ChatMessage> Messages { get; }
}

21
samples/VirtualizationDemo/ViewModels/ExpanderItemViewModel.cs

@ -0,0 +1,21 @@
using MiniMvvm;
namespace VirtualizationDemo.ViewModels;
public class ExpanderItemViewModel : ViewModelBase
{
private string? _header;
private bool _isExpanded;
public string? Header
{
get => _header;
set => RaiseAndSetIfChanged(ref _header, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => RaiseAndSetIfChanged(ref _isExpanded, value);
}
}

17
samples/VirtualizationDemo/ViewModels/ExpanderPageViewModel.cs

@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace VirtualizationDemo.ViewModels;
internal class ExpanderPageViewModel
{
public ExpanderPageViewModel()
{
Items = new(Enumerable.Range(0, 100).Select(x => new ExpanderItemViewModel
{
Header = $"Item {x}",
}));
}
public ObservableCollection<ExpanderItemViewModel> Items { get; set; }
}

26
samples/VirtualizationDemo/ViewModels/ItemViewModel.cs

@ -1,26 +0,0 @@
using System;
using MiniMvvm;
namespace VirtualizationDemo.ViewModels
{
internal class ItemViewModel : ViewModelBase
{
private string _prefix;
private int _index;
private double _height = double.NaN;
public ItemViewModel(int index, string prefix = "Item")
{
_prefix = prefix;
_index = index;
}
public string Header => $"{_prefix} {_index}";
public double Height
{
get => _height;
set => this.RaiseAndSetIfChanged(ref _height, value);
}
}
}

164
samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs

@ -1,160 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Controls.Selection;
using MiniMvvm;
using MiniMvvm;
namespace VirtualizationDemo.ViewModels
{
internal class MainWindowViewModel : ViewModelBase
{
private int _itemCount = 200;
private string _newItemString = "New Item";
private int _newItemIndex;
private AvaloniaList<ItemViewModel> _items;
private string _prefix = "Item";
private ScrollBarVisibility _horizontalScrollBarVisibility = ScrollBarVisibility.Auto;
private ScrollBarVisibility _verticalScrollBarVisibility = ScrollBarVisibility.Auto;
private Orientation _orientation = Orientation.Vertical;
public MainWindowViewModel()
{
this.WhenAnyValue(x => x.ItemCount).Subscribe(ResizeItems);
RecreateCommand = MiniCommand.Create(() => Recreate());
AddItemCommand = MiniCommand.Create(() => AddItem());
RemoveItemCommand = MiniCommand.Create(() => Remove());
SelectFirstCommand = MiniCommand.Create(() => SelectItem(0));
SelectLastCommand = MiniCommand.Create(() => SelectItem(Items.Count - 1));
}
public string NewItemString
{
get { return _newItemString; }
set { this.RaiseAndSetIfChanged(ref _newItemString, value); }
}
public int ItemCount
{
get { return _itemCount; }
set { this.RaiseAndSetIfChanged(ref _itemCount, value); }
}
public SelectionModel<ItemViewModel> Selection { get; } = new SelectionModel<ItemViewModel>();
public AvaloniaList<ItemViewModel> Items
{
get { return _items; }
private set { this.RaiseAndSetIfChanged(ref _items, value); }
}
public Orientation Orientation
{
get { return _orientation; }
set { this.RaiseAndSetIfChanged(ref _orientation, value); }
}
public IEnumerable<Orientation> Orientations =>
Enum.GetValues(typeof(Orientation)).Cast<Orientation>();
public ScrollBarVisibility HorizontalScrollBarVisibility
{
get { return _horizontalScrollBarVisibility; }
set { this.RaiseAndSetIfChanged(ref _horizontalScrollBarVisibility, value); }
}
namespace VirtualizationDemo.ViewModels;
public ScrollBarVisibility VerticalScrollBarVisibility
{
get { return _verticalScrollBarVisibility; }
set { this.RaiseAndSetIfChanged(ref _verticalScrollBarVisibility, value); }
}
public IEnumerable<ScrollBarVisibility> ScrollBarVisibilities =>
Enum.GetValues(typeof(ScrollBarVisibility)).Cast<ScrollBarVisibility>();
public MiniCommand AddItemCommand { get; private set; }
public MiniCommand RecreateCommand { get; private set; }
public MiniCommand RemoveItemCommand { get; private set; }
public MiniCommand SelectFirstCommand { get; private set; }
public MiniCommand SelectLastCommand { get; private set; }
public void RandomizeSize()
{
var random = new Random();
foreach (var i in Items)
{
i.Height = random.Next(240) + 10;
}
}
public void ResetSize()
{
foreach (var i in Items)
{
i.Height = double.NaN;
}
}
private void ResizeItems(int count)
{
if (Items == null)
{
var items = Enumerable.Range(0, count)
.Select(x => new ItemViewModel(x));
Items = new AvaloniaList<ItemViewModel>(items);
}
else if (count > Items.Count)
{
var items = Enumerable.Range(Items.Count, count - Items.Count)
.Select(x => new ItemViewModel(x));
Items.AddRange(items);
}
else if (count < Items.Count)
{
Items.RemoveRange(count, Items.Count - count);
}
}
private void AddItem()
{
var index = Items.Count;
if (Selection.SelectedItems.Count > 0)
{
index = Selection.SelectedIndex;
}
Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString));
}
private void Remove()
{
if (Selection.SelectedItems.Count > 0)
{
Items.RemoveAll(Selection.SelectedItems.ToList());
}
}
private void Recreate()
{
_prefix = _prefix == "Item" ? "Recreated" : "Item";
var items = Enumerable.Range(0, _itemCount)
.Select(x => new ItemViewModel(x, _prefix));
Items = new AvaloniaList<ItemViewModel>(items);
}
private void SelectItem(int index)
{
Selection.SelectedIndex = index;
}
}
internal class MainWindowViewModel : ViewModelBase
{
public PlaygroundPageViewModel Playground { get; } = new();
public ChatPageViewModel Chat { get; } = new();
public ExpanderPageViewModel Expanders { get; } = new();
}

17
samples/VirtualizationDemo/ViewModels/PlaygroundItemViewModel.cs

@ -0,0 +1,17 @@
using MiniMvvm;
namespace VirtualizationDemo.ViewModels;
public class PlaygroundItemViewModel : ViewModelBase
{
private string? _header;
public PlaygroundItemViewModel(int index) => Header = $"Item {index}";
public PlaygroundItemViewModel(string? header) => Header = header;
public string? Header
{
get => _header;
set => RaiseAndSetIfChanged(ref _header, value);
}
}

95
samples/VirtualizationDemo/ViewModels/PlaygroundPageViewModel.cs

@ -0,0 +1,95 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Selection;
using MiniMvvm;
namespace VirtualizationDemo.ViewModels;
public class PlaygroundPageViewModel : ViewModelBase
{
private SelectionMode _selectionMode = SelectionMode.Multiple;
private int _scrollToIndex = 500;
private string? _newItemHeader = "New Item 1";
public PlaygroundPageViewModel()
{
Items = new(Enumerable.Range(0, 1000).Select(x => new PlaygroundItemViewModel(x)));
Selection = new();
}
public ObservableCollection<PlaygroundItemViewModel> Items { get; }
public bool Multiple
{
get => _selectionMode.HasAnyFlag(SelectionMode.Multiple);
set => SetSelectionMode(SelectionMode.Multiple, value);
}
public bool Toggle
{
get => _selectionMode.HasAnyFlag(SelectionMode.Toggle);
set => SetSelectionMode(SelectionMode.Toggle, value);
}
public bool AlwaysSelected
{
get => _selectionMode.HasAnyFlag(SelectionMode.AlwaysSelected);
set => SetSelectionMode(SelectionMode.AlwaysSelected, value);
}
public SelectionModel<PlaygroundItemViewModel> Selection { get; }
public SelectionMode SelectionMode
{
get => _selectionMode;
set => RaiseAndSetIfChanged(ref _selectionMode, value);
}
public int ScrollToIndex
{
get => _scrollToIndex;
set => RaiseAndSetIfChanged(ref _scrollToIndex, value);
}
public string? NewItemHeader
{
get => _newItemHeader;
set => RaiseAndSetIfChanged(ref _newItemHeader, value);
}
public void ExecuteScrollToIndex()
{
Selection.Select(ScrollToIndex);
}
public void RandomizeScrollToIndex()
{
var rnd = new Random();
ScrollToIndex = rnd.Next(Items.Count);
}
public void AddAtSelectedIndex()
{
if (Selection.SelectedIndex == -1)
return;
Items.Insert(Selection.SelectedIndex, new(NewItemHeader));
}
public void DeleteSelectedItem()
{
var count = Selection.Count;
for (var i = count - 1; i >= 0; i--)
Items.RemoveAt(Selection.SelectedIndexes[i]);
}
private void SetSelectionMode(SelectionMode mode, bool value)
{
if (value)
SelectionMode |= mode;
else
SelectionMode &= ~mode;
}
}

39
samples/VirtualizationDemo/Views/ChatPageView.axaml

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:VirtualizationDemo.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="VirtualizationDemo.Views.ChatPageView"
x:DataType="vm:ChatPageViewModel">
<ListBox ItemsSource="{Binding Messages}">
<ListBox.ItemContainerTheme>
<ControlTheme TargetType="ListBoxItem" BasedOn="{StaticResource {x:Type ListBoxItem}}">
<Setter Property="Padding" Value="8"/>
</ControlTheme>
</ListBox.ItemContainerTheme>
<ListBox.ItemTemplate>
<DataTemplate>
<Border CornerRadius="8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
TextElement.Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"
Padding="6"
HorizontalAlignment="Left"
MaxWidth="280">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="{Binding Sender}"
FontWeight="Bold"/>
<TextBlock DockPanel.Dock="Bottom"
Text="{Binding Timestamp}"
FontSize="10"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
TextAlignment="Right"
Margin="0 4 0 0"/>
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
</DockPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</UserControl>

11
samples/VirtualizationDemo/Views/ChatPageView.axaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace VirtualizationDemo.Views;
public partial class ChatPageView : UserControl
{
public ChatPageView()
{
InitializeComponent();
}
}

18
samples/VirtualizationDemo/Views/ExpanderPageView.axaml

@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:VirtualizationDemo.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="VirtualizationDemo.Views.ExpanderPageView"
x:DataType="vm:ExpanderPageViewModel">
<ListBox ItemsSource="{Binding Items}">
<ListBox.ItemTemplate>
<DataTemplate>
<Expander Header="{Binding Header}" IsExpanded="{Binding IsExpanded}">
<Border Width="200" Height="300"/>
</Expander>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</UserControl>

13
samples/VirtualizationDemo/Views/ExpanderPageView.axaml.cs

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace VirtualizationDemo.Views;
public partial class ExpanderPageView : UserControl
{
public ExpanderPageView()
{
InitializeComponent();
}
}

66
samples/VirtualizationDemo/Views/PlaygroundPageView.axaml

@ -0,0 +1,66 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:VirtualizationDemo.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="VirtualizationDemo.Views.PlaygroundPageView"
x:DataType="vm:PlaygroundPageViewModel">
<DockPanel>
<StackPanel DockPanel.Dock="Right" Margin="8 0" Width="200">
<DropDownButton Content="Selection" HorizontalAlignment="Stretch">
<Button.Flyout>
<Flyout>
<StackPanel>
<CheckBox IsChecked="{Binding Multiple}">Multiple</CheckBox>
<CheckBox IsChecked="{Binding Toggle}">Toggle</CheckBox>
<CheckBox IsChecked="{Binding AlwaysSelected}">AlwaysSelected</CheckBox>
<CheckBox IsChecked="{Binding #list.AutoScrollToSelectedItem}">AutoScrollToSelectedItem</CheckBox>
<CheckBox IsChecked="{Binding #list.WrapSelection}">WrapSelection</CheckBox>
</StackPanel>
</Flyout>
</Button.Flyout>
</DropDownButton>
<Label>_Select Item</Label>
<DockPanel>
<TextBox x:Name="scrollToIndex" Text="{Binding ScrollToIndex}">
<TextBox.InnerRightContent>
<StackPanel Orientation="Horizontal">
<Button DockPanel.Dock="Right"
Command="{Binding RandomizeScrollToIndex}"
ToolTip.Tip="Randomize">
&#x27F3;
</Button>
<Button DockPanel.Dock="Right"
Command="{Binding ExecuteScrollToIndex}"
ToolTip.Tip="Execute">
&#11152;
</Button>
</StackPanel>
</TextBox.InnerRightContent>
</TextBox>
</DockPanel>
<Label>New Item</Label>
<TextBox Text="{Binding NewItemHeader}">
<TextBox.InnerRightContent>
<Button Command="{Binding AddAtSelectedIndex}"
ToolTip.Tip="Add at Selected Index">&#x2B;</Button>
</TextBox.InnerRightContent>
</TextBox>
<Button Command="{Binding DeleteSelectedItem}" Margin="0 8 0 0">
Delete Selected
</Button>
</StackPanel>
<TextBlock Name="itemCount" DockPanel.Dock="Bottom"/>
<ListBox Name="list"
ItemsSource="{Binding Items}"
DisplayMemberBinding="{Binding Header}"
Selection="{Binding Selection}"
SelectionMode="{Binding SelectionMode}"/>
</DockPanel>
</UserControl>

44
samples/VirtualizationDemo/Views/PlaygroundPageView.axaml.cs

@ -0,0 +1,44 @@
using System;
using System.Linq;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace VirtualizationDemo.Views;
public partial class PlaygroundPageView : UserControl
{
private DispatcherTimer _timer;
public PlaygroundPageView()
{
InitializeComponent();
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(500),
};
_timer.Tick += TimerTick;
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_timer.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_timer.Stop();
}
private void TimerTick(object? sender, EventArgs e)
{
var message = $"Realized {list.GetRealizedContainers().Count()} of {list.ItemsPanelRoot?.Children.Count}";
itemCount.Text = message;
}
}

21
samples/VirtualizationDemo/VirtualizationDemo.csproj

@ -1,19 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<IncludeAvaloniaGenerators>true</IncludeAvaloniaGenerators>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\packages\Avalonia\Avalonia.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Desktop\Avalonia.Desktop.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
<ProjectReference Include="..\..\src\Linux\Avalonia.LinuxFramebuffer\Avalonia.LinuxFramebuffer.csproj" />
<ProjectReference Include="..\MiniMvvm\MiniMvvm.csproj" />
<ProjectReference Include="..\SampleControls\ControlSamples.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\chat.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Import Project="..\..\build\SampleApp.props" />
<Import Project="..\..\build\EmbedXaml.props" />
<Import Project="..\..\build\Rx.props" />
<Import Condition="'$(TargetFramework)'=='net461'" Project="..\..\build\NetFX.props" />
<Import Project="..\..\build\ReferenceCoreLibraries.props" />
<Import Project="..\..\build\BuildTargets.targets" />
<Import Project="..\..\build\SourceGenerators.props" />
<Import Project="..\..\build\NullableEnable.props" />
</Project>

26
src/Avalonia.Base/Controls/ResourceDictionary.cs

@ -15,6 +15,7 @@ namespace Avalonia.Controls
/// </summary>
public class ResourceDictionary : IResourceDictionary
{
private object? lastDeferredItemKey;
private Dictionary<object, object?>? _inner;
private IResourceHost? _owner;
private AvaloniaList<IResourceProvider>? _mergedDictionaries;
@ -241,12 +242,27 @@ namespace Avalonia.Controls
{
if (value is DeferredItem deffered)
{
_inner[key] = value = deffered.Factory(null) switch
// Avoid simple reentrancy, which could commonly occur on redefining the resource.
if (lastDeferredItemKey == key)
{
ITemplateResult t => t.Result,
object v => v,
_ => null,
};
value = null;
return false;
}
try
{
lastDeferredItemKey = key;
_inner[key] = value = deffered.Factory(null) switch
{
ITemplateResult t => t.Result,
{ } v => v,
_ => null,
};
}
finally
{
lastDeferredItemKey = null;
}
}
return true;
}

4
src/Avalonia.Base/Media/DrawingContext.cs

@ -132,7 +132,7 @@ namespace Avalonia.Media
double radiusX = 0, double radiusY = 0,
BoxShadows boxShadows = default)
{
if (brush == null && !PenIsVisible(pen))
if (brush == null && !PenIsVisible(pen) && boxShadows.Count == 0)
return;
if (!MathUtilities.IsZero(radiusX))
{
@ -160,7 +160,7 @@ namespace Avalonia.Media
/// </remarks>
public void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default)
{
if (brush == null && !PenIsVisible(pen))
if (brush == null && !PenIsVisible(pen) && boxShadows.Count == 0)
return;
DrawRectangleCore(brush, pen, rrect, boxShadows);
}

56
src/Avalonia.Base/Media/FontManager.cs

@ -30,13 +30,15 @@ namespace Avalonia.Media
_fontFallbacks = options?.FontFallbacks;
DefaultFontFamilyName = options?.DefaultFamilyName ?? PlatformImpl.GetDefaultFontFamilyName();
var defaultFontFamilyName = options?.DefaultFamilyName ?? PlatformImpl.GetDefaultFontFamilyName();
if (string.IsNullOrEmpty(DefaultFontFamilyName))
if (string.IsNullOrEmpty(defaultFontFamilyName))
{
throw new InvalidOperationException("Default font family name can't be null or empty.");
}
DefaultFontFamily = new FontFamily(defaultFontFamilyName);
AddFontCollection(new SystemFontCollection(this));
}
@ -65,9 +67,9 @@ namespace Avalonia.Media
}
/// <summary>
/// Gets the system's default font family's name.
/// Gets the system's default font family.
/// </summary>
public string DefaultFontFamilyName
public FontFamily DefaultFontFamily
{
get;
}
@ -93,6 +95,11 @@ namespace Avalonia.Media
var fontFamily = typeface.FontFamily;
if(typeface.FontFamily.Name == FontFamily.DefaultFontFamilyName)
{
return TryGetGlyphTypeface(new Typeface(DefaultFontFamily, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface);
}
if (fontFamily.Key is FontFamilyKey key)
{
var source = key.Source;
@ -131,15 +138,21 @@ namespace Avalonia.Media
}
}
foreach (var familyName in fontFamily.FamilyNames)
for (var i = 0; i < fontFamily.FamilyNames.Count; i++)
{
var familyName = fontFamily.FamilyNames[i];
if (SystemFonts.TryGetGlyphTypeface(familyName, typeface.Style, typeface.Weight, typeface.Stretch, out glyphTypeface))
{
return true;
if (!fontFamily.FamilyNames.HasFallbacks || glyphTypeface.FamilyName != DefaultFontFamily.Name)
{
return true;
}
}
}
return TryGetGlyphTypeface(new Typeface(DefaultFontFamilyName, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface);
//Nothing was found so use the default
return TryGetGlyphTypeface(new Typeface(DefaultFontFamily, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface);
}
/// <summary>
@ -199,16 +212,37 @@ namespace Avalonia.Media
{
foreach (var fallback in _fontFallbacks)
{
typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch);
if (fallback.UnicodeRange.IsInRange(codepoint))
{
typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch);
if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.TryGetGlyph((uint)codepoint, out _))
{
return true;
}
}
}
}
if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.TryGetGlyph((uint)codepoint, out _))
//Try to match against fallbacks first
if (fontFamily != null && fontFamily.FamilyNames.HasFallbacks)
{
for (int i = 1; i < fontFamily.FamilyNames.Count; i++)
{
var familyName = fontFamily.FamilyNames[i];
foreach (var fontCollection in _fontCollections.Values)
{
return true;
if (fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface))
{
return true;
};
}
}
}
return PlatformImpl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily, culture, out typeface);
//Try to find a match with the system font manager
return PlatformImpl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, culture, out typeface);
}
}
}

206
src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs

@ -8,10 +8,8 @@ using Avalonia.Platform;
namespace Avalonia.Media.Fonts
{
public class EmbeddedFontCollection : IFontCollection
public class EmbeddedFontCollection : FontCollectionBase
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, IGlyphTypeface>> _glyphTypefaceCache = new();
private readonly List<FontFamily> _fontFamilies = new List<FontFamily>(1);
private readonly Uri _key;
@ -25,13 +23,13 @@ namespace Avalonia.Media.Fonts
_source = source;
}
public Uri Key => _key;
public override Uri Key => _key;
public FontFamily this[int index] => _fontFamilies[index];
public override FontFamily this[int index] => _fontFamilies[index];
public int Count => _fontFamilies.Count;
public override int Count => _fontFamilies.Count;
public void Initialize(IFontManagerImpl fontManager)
public override void Initialize(IFontManagerImpl fontManager)
{
var assetLoader = AvaloniaLocator.Current.GetRequiredService<IAssetLoader>();
@ -45,7 +43,7 @@ namespace Avalonia.Media.Fonts
{
if (!_glyphTypefaceCache.TryGetValue(glyphTypeface.FamilyName, out var glyphTypefaces))
{
glyphTypefaces = new ConcurrentDictionary<FontCollectionKey, IGlyphTypeface>();
glyphTypefaces = new ConcurrentDictionary<FontCollectionKey, IGlyphTypeface?>();
if (_glyphTypefaceCache.TryAdd(glyphTypeface.FamilyName, glyphTypefaces))
{
@ -63,27 +61,8 @@ namespace Avalonia.Media.Fonts
}
}
public void Dispose()
{
foreach (var fontFamily in _fontFamilies)
{
if (_glyphTypefaceCache.TryGetValue(fontFamily.Name, out var glyphTypefaces))
{
foreach (var glyphTypeface in glyphTypefaces.Values)
{
glyphTypeface.Dispose();
}
}
}
GC.SuppressFinalize(this);
}
public IEnumerator<FontFamily> GetEnumerator() => _fontFamilies.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
public override bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
var key = new FontCollectionKey(style, weight, stretch);
@ -116,175 +95,6 @@ namespace Avalonia.Media.Fonts
return false;
}
private static bool TryGetNearestMatch(
ConcurrentDictionary<FontCollectionKey, IGlyphTypeface> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
if (glyphTypefaces.TryGetValue(key, out glyphTypeface))
{
return true;
}
if (key.Style != FontStyle.Normal)
{
key = key with { Style = FontStyle.Normal };
}
if (key.Stretch != FontStretch.Normal)
{
if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
if (key.Weight != FontWeight.Normal)
{
if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, out glyphTypeface))
{
return true;
}
}
key = key with { Stretch = FontStretch.Normal };
}
if (TryFindWeightFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
//Take the first glyph typeface we can find.
foreach (var typeface in glyphTypefaces.Values)
{
glyphTypeface = typeface;
return true;
}
return false;
}
private static bool TryFindStretchFallback(
ConcurrentDictionary<FontCollectionKey, IGlyphTypeface> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
glyphTypeface = null;
var stretch = (int)key.Stretch;
if (stretch < 5)
{
for (var i = 0; stretch + i < 9; i++)
{
if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch + i) }, out glyphTypeface))
{
return true;
}
}
}
else
{
for (var i = 0; stretch - i > 1; i++)
{
if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch - i) }, out glyphTypeface))
{
return true;
}
}
}
return false;
}
private static bool TryFindWeightFallback(
ConcurrentDictionary<FontCollectionKey, IGlyphTypeface> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? typeface)
{
typeface = null;
var weight = (int)key.Weight;
//If the target weight given is between 400 and 500 inclusive
if (weight >= 400 && weight <= 500)
{
//Look for available weights between the target and 500, in ascending order.
for (var i = 0; weight + i <= 500; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface))
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface))
{
return true;
}
}
//If no match is found, look for available weights greater than 500, in ascending order.
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface))
{
return true;
}
}
}
//If a weight less than 400 is given, look for available weights less than the target, in descending order.
if (weight < 400)
{
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface))
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface))
{
return true;
}
}
}
//If a weight greater than 500 is given, look for available weights greater than the target, in ascending order.
if (weight > 500)
{
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface))
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface))
{
return true;
}
}
}
return false;
}
public override IEnumerator<FontFamily> GetEnumerator() => _fontFamilies.GetEnumerator();
}
}

259
src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs

@ -0,0 +1,259 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Platform;
namespace Avalonia.Media.Fonts
{
public abstract class FontCollectionBase : IFontCollection
{
protected readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, IGlyphTypeface?>> _glyphTypefaceCache = new();
public abstract Uri Key { get; }
public abstract int Count { get; }
public abstract FontFamily this[int index] { get; }
public abstract bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface);
public bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
string? familyName, CultureInfo? culture, out Typeface match)
{
match = default;
if (string.IsNullOrEmpty(familyName))
{
foreach (var typefaces in _glyphTypefaceCache.Values)
{
if (TryGetNearestMatch(typefaces, new FontCollectionKey { Style = style, Weight = weight, Stretch = stretch }, out var glyphTypeface))
{
if (glyphTypeface.TryGetGlyph((uint)codepoint, out _))
{
match = new Typeface(glyphTypeface.FamilyName, style, weight, stretch);
return true;
}
}
}
}
else
{
if (TryGetGlyphTypeface(familyName, style, weight, stretch, out var glyphTypeface))
{
if (glyphTypeface.TryGetGlyph((uint)codepoint, out _))
{
match = new Typeface(familyName, style, weight, stretch);
return true;
}
}
}
return false;
}
public abstract void Initialize(IFontManagerImpl fontManager);
public abstract IEnumerator<FontFamily> GetEnumerator();
void IDisposable.Dispose()
{
foreach (var glyphTypefaces in _glyphTypefaceCache.Values)
{
foreach (var pair in glyphTypefaces)
{
pair.Value?.Dispose();
}
}
GC.SuppressFinalize(this);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
internal static bool TryGetNearestMatch(
ConcurrentDictionary<FontCollectionKey,
IGlyphTypeface?> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
if (key.Style != FontStyle.Normal)
{
key = key with { Style = FontStyle.Normal };
}
if (key.Stretch != FontStretch.Normal)
{
if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
if (key.Weight != FontWeight.Normal)
{
if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, out glyphTypeface))
{
return true;
}
}
key = key with { Stretch = FontStretch.Normal };
}
if (TryFindWeightFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface))
{
return true;
}
//Take the first glyph typeface we can find.
foreach (var typeface in glyphTypefaces.Values)
{
if(typeface != null)
{
glyphTypeface = typeface;
return true;
}
}
return false;
}
internal static bool TryFindStretchFallback(
ConcurrentDictionary<FontCollectionKey,
IGlyphTypeface?> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
glyphTypeface = null;
var stretch = (int)key.Stretch;
if (stretch < 5)
{
for (var i = 0; stretch + i < 9; i++)
{
if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch + i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
}
else
{
for (var i = 0; stretch - i > 1; i++)
{
if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch - i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
}
return false;
}
internal static bool TryFindWeightFallback(
ConcurrentDictionary<FontCollectionKey,
IGlyphTypeface?> glyphTypefaces,
FontCollectionKey key,
[NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
glyphTypeface = null;
var weight = (int)key.Weight;
//If the target weight given is between 400 and 500 inclusive
if (weight >= 400 && weight <= 500)
{
//Look for available weights between the target and 500, in ascending order.
for (var i = 0; weight + i <= 500; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
//If no match is found, look for available weights greater than 500, in ascending order.
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
}
//If a weight less than 400 is given, look for available weights less than the target, in descending order.
if (weight < 400)
{
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
}
//If a weight greater than 500 is given, look for available weights greater than the target, in ascending order.
if (weight > 500)
{
for (var i = 0; weight + i <= 900; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
//If no match is found, look for available weights less than the target, in descending order.
for (var i = 0; weight - i >= 100; i += 50)
{
if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null)
{
return true;
}
}
}
return false;
}
}
}

17
src/Avalonia.Base/Media/Fonts/IFontCollection.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Platform;
namespace Avalonia.Media.Fonts
@ -29,5 +30,21 @@ namespace Avalonia.Media.Fonts
/// <returns>Returns <c>true</c> if a glyph typface can be found; otherwise, <c>false</c></returns>
bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface);
/// <summary>
/// Tries to match a specified character to a <see cref="Typeface"/> that supports specified font properties.
/// </summary>
/// <param name="codepoint">The codepoint to match against.</param>
/// <param name="fontStyle">The font style.</param>
/// <param name="fontWeight">The font weight.</param>
/// <param name="fontStretch">The font stretch.</param>
/// <param name="familyName">The family name. This is optional and used for fallback lookup.</param>
/// <param name="culture">The culture.</param>
/// <param name="typeface">The matching <see cref="Typeface"/>.</param>
/// <returns>
/// <c>True</c>, if the <see cref="FontManager"/> could match the character to specified parameters, <c>False</c> otherwise.
/// </returns>
bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, string? familyName, CultureInfo? culture, out Typeface typeface);
}
}

66
src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs

@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
@ -7,10 +6,8 @@ using Avalonia.Platform;
namespace Avalonia.Media.Fonts
{
internal class SystemFontCollection : IFontCollection
internal class SystemFontCollection : FontCollectionBase
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, IGlyphTypeface>> _glyphTypefaceCache = new();
private readonly FontManager _fontManager;
private readonly string[] _familyNames;
@ -20,9 +17,9 @@ namespace Avalonia.Media.Fonts
_familyNames = fontManager.PlatformImpl.GetInstalledFontFamilyNames();
}
public Uri Key => FontManager.SystemFontsKey;
public override Uri Key => FontManager.SystemFontsKey;
public FontFamily this[int index]
public override FontFamily this[int index]
{
get
{
@ -32,76 +29,41 @@ namespace Avalonia.Media.Fonts
}
}
public int Count => _familyNames.Length;
public override int Count => _familyNames.Length;
public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
public override bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
if (familyName == FontFamily.DefaultFontFamilyName)
{
familyName = _fontManager.DefaultFontFamilyName;
}
glyphTypeface = null;
var key = new FontCollectionKey(style, weight, stretch);
if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces))
{
if (glyphTypefaces.TryGetValue(key, out glyphTypeface))
{
return true;
}
else
{
if (_fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface) &&
glyphTypefaces.TryAdd(key, glyphTypeface))
{
return true;
}
}
}
var glyphTypefaces = _glyphTypefaceCache.GetOrAdd(familyName, (key) => new ConcurrentDictionary<FontCollectionKey, IGlyphTypeface?>());
if (_fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface))
if (!glyphTypefaces.TryGetValue(key, out glyphTypeface))
{
glyphTypefaces = new ConcurrentDictionary<FontCollectionKey, IGlyphTypeface>();
_fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface);
if (glyphTypefaces.TryAdd(key, glyphTypeface) && _glyphTypefaceCache.TryAdd(familyName, glyphTypefaces))
if (!glyphTypefaces.TryAdd(key, glyphTypeface))
{
return true;
return false;
}
}
return false;
return glyphTypeface != null;
}
public void Initialize(IFontManagerImpl fontManager)
public override void Initialize(IFontManagerImpl fontManager)
{
//We initialize the system font collection during construction.
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public IEnumerator<FontFamily> GetEnumerator()
public override IEnumerator<FontFamily> GetEnumerator()
{
foreach (var familyName in _familyNames)
{
yield return new FontFamily(familyName);
}
}
void IDisposable.Dispose()
{
foreach (var glyphTypefaces in _glyphTypefaceCache.Values)
{
foreach (var pair in glyphTypefaces)
{
pair.Value.Dispose();
}
}
GC.SuppressFinalize(this);
}
}
}

4
src/Avalonia.Base/Platform/IFontManagerImpl.cs

@ -27,15 +27,13 @@ namespace Avalonia.Platform
/// <param name="fontStyle">The font style.</param>
/// <param name="fontWeight">The font weight.</param>
/// <param name="fontStretch">The font stretch.</param>
/// <param name="fontFamily">The font family. This is optional and used for fallback lookup.</param>
/// <param name="culture">The culture.</param>
/// <param name="typeface">The matching typeface.</param>
/// <returns>
/// <c>True</c>, if the <see cref="IFontManagerImpl"/> could match the character to specified parameters, <c>False</c> otherwise.
/// </returns>
bool TryMatchCharacter(int codepoint, FontStyle fontStyle,
FontWeight fontWeight, FontStretch fontStretch,
FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface);
FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface);
/// <summary>
/// Tries to get a glyph typeface for specified parameters.

13
src/Avalonia.Controls/ISelectable.cs

@ -1,16 +1,9 @@
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
/// <summary>
/// Interface for objects that are selectable.
/// An interface that is implemented by objects that expose their selection state via a
/// boolean <see cref="IsSelected"/> property.
/// </summary>
/// <remarks>
/// Controls such as <see cref="SelectingItemsControl"/> use this interface to indicate the
/// selected control in a list. If changing the control's <see cref="IsSelected"/> property
/// should update the selection in a <see cref="SelectingItemsControl"/> or equivalent, then
/// the control should raise the <see cref="SelectingItemsControl.IsSelectedChangedEvent"/>.
/// </remarks>
public interface ISelectable
{
/// <summary>
@ -18,4 +11,4 @@ namespace Avalonia.Controls
/// </summary>
bool IsSelected { get; set; }
}
}
}

48
src/Avalonia.Controls/ItemsControl.cs

@ -94,7 +94,6 @@ namespace Avalonia.Controls
private ItemContainerGenerator? _itemContainerGenerator;
private EventHandler<ChildIndexChangedEventArgs>? _childIndexChanged;
private IDataTemplate? _displayMemberItemTemplate;
private ScrollViewer? _scrollViewer;
private ItemsPresenter? _itemsPresenter;
/// <summary>
@ -425,6 +424,21 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Called when a container has been fully prepared to display an item.
/// </summary>
/// <param name="container">The container control.</param>
/// <param name="item">The item being displayed.</param>
/// <param name="index">The index of the item being displayed.</param>
/// <remarks>
/// This method will be called when a container has been fully prepared and added to the
/// logical and visual trees, but may be called before a layout pass has completed. It is
/// called immediately before the <see cref="ContainerPrepared"/> event is raised.
/// </remarks>
protected internal virtual void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
}
/// <summary>
/// Called when the index for a container changes due to an insertion or removal in the
/// items collection.
@ -442,6 +456,34 @@ namespace Avalonia.Controls
/// <param name="container">The container element.</param>
protected internal virtual void ClearContainerForItemOverride(Control container)
{
if (container is HeaderedContentControl hcc)
{
if (hcc.Content is Control)
hcc.Content = null;
if (hcc.Header is Control)
hcc.Header = null;
}
else if (container is ContentControl cc)
{
if (cc.Content is Control)
cc.Content = null;
}
else if (container is ContentPresenter p)
{
if (p.Content is Control)
p.Content = null;
}
else if (container is HeaderedItemsControl hic)
{
if (hic.Header is Control)
hic.Header = null;
}
else if (container is HeaderedSelectingItemsControl hsic)
{
if (hsic.Header is Control)
hsic.Header = null;
}
// Feels like we should be clearing the HeaderedItemsControl.Items binding here, but looking at
// the WPF source it seems that this isn't done there.
}
@ -457,7 +499,6 @@ namespace Avalonia.Controls
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_scrollViewer = e.NameScope.Find<ScrollViewer>("PART_ScrollViewer");
_itemsPresenter = e.NameScope.Find<ItemsPresenter>("PART_ItemsPresenter");
}
@ -628,8 +669,8 @@ namespace Avalonia.Controls
internal void ItemContainerPrepared(Control container, object? item, int index)
{
ContainerForItemPreparedOverride(container, item, index);
_childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, index));
_scrollViewer?.RegisterAnchorCandidate(container);
ContainerPrepared?.Invoke(this, new(container, index));
}
@ -642,7 +683,6 @@ namespace Avalonia.Controls
internal void ClearItemContainer(Control container)
{
_scrollViewer?.UnregisterAnchorCandidate(container);
ClearContainerForItemOverride(container);
ContainerClearing?.Invoke(this, new(container));
}

3
src/Avalonia.Controls/ListBoxItem.cs

@ -1,6 +1,7 @@
using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
@ -14,7 +15,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.Register<ListBoxItem, bool>(nameof(IsSelected));
SelectingItemsControl.IsSelectedProperty.AddOwner<ListBoxItem>();
/// <summary>
/// Initializes static members of the <see cref="ListBoxItem"/> class.

2
src/Avalonia.Controls/MenuItem.cs

@ -57,7 +57,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<MenuItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<MenuItem>();
/// <summary>
/// Defines the <see cref="IsSubMenuOpen"/> property.

4
src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs

@ -85,7 +85,7 @@ namespace Avalonia.Controls.Presenters
private Size _viewport;
private Dictionary<int, Vector>? _activeLogicalGestureScrolls;
private Dictionary<int, Vector>? _scrollGestureSnapPoints;
private List<Control>? _anchorCandidates;
private HashSet<Control>? _anchorCandidates;
private Control? _anchorElement;
private Rect _anchorElementBounds;
private bool _isAnchorElementDirty;
@ -347,7 +347,7 @@ namespace Avalonia.Controls.Presenters
"An anchor control must be a visual descendent of the ScrollContentPresenter.");
}
_anchorCandidates ??= new List<Control>();
_anchorCandidates ??= new();
_anchorCandidates.Add(element);
_isAnchorElementDirty = true;
}

1
src/Avalonia.Controls/Primitives/PopupRoot.cs

@ -90,7 +90,6 @@ namespace Avalonia.Controls.Primitives
public void Dispose()
{
PlatformImpl?.Dispose();
HandleClosed();
}
private void UpdatePosition()

123
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -104,6 +104,14 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<SelectingItemsControl, SelectionMode>(
nameof(SelectionMode));
/// <summary>
/// Defines the IsSelected attached property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.RegisterAttached<SelectingItemsControl, Control, bool>(
"IsSelected",
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="IsTextSearchEnabled"/> property.
/// </summary>
@ -111,9 +119,8 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(IsTextSearchEnabled), false);
/// <summary>
/// Event that should be raised by items that implement <see cref="ISelectable"/> to
/// notify the parent <see cref="SelectingItemsControl"/> that their selection state
/// has changed.
/// Event that should be raised by containers when their selection state changes to notify
/// the parent <see cref="SelectingItemsControl"/> that their selection state has changed.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> IsSelectedChangedEvent =
RoutedEvent.Register<SelectingItemsControl, RoutedEventArgs>(
@ -302,20 +309,9 @@ namespace Avalonia.Controls.Primitives
{
get
{
if (_updateState?.Selection.HasValue == true)
{
return _updateState.Selection.Value;
}
else
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
return _updateState?.Selection.HasValue == true ?
_updateState.Selection.Value :
GetOrCreateSelectionModel();
}
set
{
@ -420,6 +416,21 @@ namespace Avalonia.Controls.Primitives
/// <param name="item">The item.</param>
public void ScrollIntoView(object item) => ScrollIntoView(ItemsView.IndexOf(item));
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <returns>The value of the attached property.</returns>
public static bool GetIsSelected(Control control) => control.GetValue(IsSelectedProperty);
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="value">The value of the property.</param>
/// <returns>The value of the attached property.</returns>
public static void SetIsSelected(Control control, bool value) => control.SetValue(IsSelectedProperty, value);
/// <summary>
/// Tries to get the container that was the source of an event.
/// </summary>
@ -473,20 +484,36 @@ namespace Avalonia.Controls.Primitives
}
}
/// <inheritdoc />
protected internal override void PrepareContainerForItemOverride(Control element, object? item, int index)
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
// Ensure that the selection model is created at this point so that accessing it in
// ContainerForItemPreparedOverride doesn't cause it to be initialized (which can
// make containers become deselected when they're synced with the empty selection
// mode).
GetOrCreateSelectionModel();
base.PrepareContainerForItemOverride(container, item, index);
}
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(element, item, index);
base.ContainerForItemPreparedOverride(container, item, index);
if ((element as ISelectable)?.IsSelected == true)
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (!container.IsSet(IsSelectedProperty))
{
Selection.Select(index);
MarkContainerSelected(element, true);
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, Selection.IsSelected(index));
}
else
{
var selected = Selection.IsSelected(index);
MarkContainerSelected(element, selected);
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = GetIsSelected(container);
UpdateSelection(index, containerIsSelected, toggleModifier: true);
}
}
@ -508,8 +535,7 @@ namespace Avalonia.Controls.Primitives
KeyboardNavigation.SetTabOnceActiveElement(panel, null);
}
if (element is ISelectable)
MarkContainerSelected(element, false);
element.ClearValue(IsSelectedProperty);
}
/// <inheritdoc/>
@ -874,6 +900,17 @@ namespace Avalonia.Controls.Primitives
return false;
}
private ISelectionModel GetOrCreateSelectionModel()
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
private void OnItemsViewSourceChanged(object? sender, EventArgs e)
{
if (_selection is not null && _updateState is null)
@ -1098,11 +1135,14 @@ namespace Avalonia.Controls.Primitives
{
if (!_ignoreContainerSelectionChanged &&
e.Source is Control control &&
e.Source is ISelectable selectable &&
control.Parent == this &&
IndexFromContainer(control) != -1)
IndexFromContainer(control) is var index &&
index >= 0)
{
UpdateSelection(control, selectable.IsSelected);
if (GetIsSelected(control))
Selection.Select(index);
else
Selection.Deselect(index);
}
if (e.Source != this)
@ -1112,31 +1152,18 @@ namespace Avalonia.Controls.Primitives
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// Sets the <see cref="IsSelectedProperty"/> on the specified container.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
/// <returns>The previous selection state.</returns>
private bool MarkContainerSelected(Control container, bool selected)
private void MarkContainerSelected(Control container, bool selected)
{
_ignoreContainerSelectionChanged = true;
try
{
bool result;
_ignoreContainerSelectionChanged = true;
if (container is ISelectable selectable)
{
result = selectable.IsSelected;
selectable.IsSelected = selected;
}
else
{
result = container.Classes.Contains(":selected");
((IPseudoClasses)container.Classes).Set(":selected", selected);
}
return result;
container.SetCurrentValue(IsSelectedProperty, selected);
}
finally
{

9
src/Avalonia.Controls/Primitives/Thumb.cs

@ -2,6 +2,7 @@ using System;
using Avalonia.Controls.Metadata;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
namespace Avalonia.Controls.Primitives
{
@ -80,20 +81,22 @@ namespace Avalonia.Controls.Primitives
{
if (_lastPoint.HasValue)
{
var point = e.GetPosition(this.GetVisualParent());
var ev = new VectorEventArgs
{
RoutedEvent = DragDeltaEvent,
Vector = e.GetPosition(this) - _lastPoint.Value,
Vector = point - _lastPoint.Value,
};
RaiseEvent(ev);
_lastPoint = point;
}
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
e.Handled = true;
_lastPoint = e.GetPosition(this);
_lastPoint = e.GetPosition(this.GetVisualParent());
var ev = new VectorEventArgs
{
@ -116,7 +119,7 @@ namespace Avalonia.Controls.Primitives
var ev = new VectorEventArgs
{
RoutedEvent = DragCompletedEvent,
Vector = (Vector)e.GetPosition(this),
Vector = (Vector)e.GetPosition(this.GetVisualParent()),
};
RaiseEvent(ev);

8
src/Avalonia.Controls/Slider.cs

@ -246,11 +246,11 @@ namespace Avalonia.Controls
break;
case Key.Home:
Value = Minimum;
SetCurrentValue(ValueProperty, Minimum);
break;
case Key.End:
Value = Maximum;
SetCurrentValue(ValueProperty, Maximum);
break;
default:
@ -313,7 +313,7 @@ namespace Avalonia.Controls
// Update if we've found a better value
if (Math.Abs(next - value) > Tolerance)
{
Value = next;
SetCurrentValue(ValueProperty, next);
}
}
@ -366,7 +366,7 @@ namespace Avalonia.Controls
var range = Maximum - Minimum;
var finalValue = calcVal * range + Minimum;
Value = IsSnapToTickEnabled ? SnapToTick(finalValue) : finalValue;
SetCurrentValue(ValueProperty, IsSnapToTickEnabled ? SnapToTick(finalValue) : finalValue);
}
/// <inheritdoc />

2
src/Avalonia.Controls/TabItem.cs

@ -22,7 +22,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<TabItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<TabItem>();
/// <summary>
/// Initializes static members of the <see cref="TabItem"/> class.

16
src/Avalonia.Controls/TopLevel.cs

@ -1,5 +1,6 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using Avalonia.Reactive;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Platform;
@ -527,6 +528,14 @@ namespace Avalonia.Controls
/// </summary>
protected virtual void HandleClosed()
{
Renderer.SceneInvalidated -= SceneInvalidated;
// We need to wait for the renderer to complete any in-flight operations
Renderer.Dispose();
Debug.Assert(PlatformImpl != null);
// The PlatformImpl is completely invalid at this point
PlatformImpl = null;
if (_globalStyles is object)
{
_globalStyles.GlobalStylesAdded -= ((IStyleHost)this).StylesAdded;
@ -536,10 +545,7 @@ namespace Avalonia.Controls
{
_applicationThemeHost.ActualThemeVariantChanged -= GlobalActualThemeVariantChanged;
}
Renderer.SceneInvalidated -= SceneInvalidated;
Renderer.Dispose();
_layoutDiagnosticBridge?.Dispose();
_layoutDiagnosticBridge = null;
@ -547,8 +553,6 @@ namespace Avalonia.Controls
_pointerOverPreProcessorSubscription?.Dispose();
_backGestureSubscription?.Dispose();
PlatformImpl = null;
var logicalArgs = new LogicalTreeAttachmentEventArgs(this, this, null);
((ILogical)this).NotifyDetachedFromLogicalTree(logicalArgs);

76
src/Avalonia.Controls/TreeView.cs

@ -10,6 +10,7 @@ using Avalonia.Controls.Generators;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Threading;
using Avalonia.VisualTree;
@ -60,7 +61,8 @@ namespace Avalonia.Controls
/// </summary>
static TreeView()
{
// HACK: Needed or SelectedItem property will not be found in Release build.
SelectingItemsControl.IsSelectedChangedEvent.AddClassHandler<TreeView>((x, e) =>
x.ContainerSelectionChanged(e));
}
/// <summary>
@ -430,9 +432,8 @@ namespace Avalonia.Controls
private void MarkItemSelected(object item, bool selected)
{
var container = TreeContainerFromItem(item)!;
MarkContainerSelected(container, selected);
if (TreeContainerFromItem(item) is Control container)
MarkContainerSelected(container, selected);
}
private void SelectedItemsAdded(IList items)
@ -487,16 +488,24 @@ namespace Avalonia.Controls
protected internal override Control CreateContainerForItemOverride() => new TreeViewItem();
protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TreeViewItem;
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
base.ContainerForItemPreparedOverride(container, item, index);
if (item == SelectedItem)
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (container.IsSet(SelectingItemsControl.IsSelectedProperty))
{
MarkContainerSelected(container, true);
if (AutoScrollToSelectedItem)
Dispatcher.UIThread.Post(container.BringIntoView);
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = SelectingItemsControl.GetIsSelected(container);
UpdateSelectionFromContainer(container, select: containerIsSelected, toggleModifier: true);
}
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, SelectedItems.Contains(item));
}
/// <inheritdoc/>
@ -663,7 +672,11 @@ namespace Avalonia.Controls
var multi = mode.HasAllFlags(SelectionMode.Multiple);
var range = multi && rangeModifier && selectedContainer != null;
if (rightButton)
if (!select)
{
SelectedItems.Remove(item);
}
else if (rightButton)
{
if (!SelectedItems.Contains(item))
{
@ -863,27 +876,44 @@ namespace Avalonia.Controls
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// Called when a container raises the
/// <see cref="SelectingItemsControl.IsSelectedChangedEvent"/>.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
private void MarkContainerSelected(Control? container, bool selected)
/// <param name="e">The event.</param>
private void ContainerSelectionChanged(RoutedEventArgs e)
{
if (container == null)
if (e.Source is TreeViewItem container &&
container.TreeViewOwner == this &&
TreeItemFromContainer(container) is object item)
{
return;
}
var containerIsSelected = SelectingItemsControl.GetIsSelected(container);
var ourIsSelected = SelectedItems.Contains(item);
if (container is ISelectable selectable)
{
selectable.IsSelected = selected;
if (containerIsSelected != ourIsSelected)
{
if (containerIsSelected)
SelectedItems.Add(item);
else
SelectedItems.Remove(item);
}
}
else
if (e.Source != this)
{
((IPseudoClasses)container.Classes).Set(":selected", selected);
e.Handled = true;
}
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
private void MarkContainerSelected(Control container, bool selected)
{
container.SetCurrentValue(SelectingItemsControl.IsSelectedProperty, selected);
}
/// <summary>
/// Makes a list of objects equal another (though doesn't preserve order).
/// </summary>

7
src/Avalonia.Controls/TreeViewItem.cs

@ -31,7 +31,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<TreeViewItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<TreeViewItem>();
/// <summary>
/// Defines the <see cref="Level"/> property.
@ -105,6 +105,11 @@ namespace Avalonia.Controls
EnsureTreeView().PrepareContainerForItemOverride(container, item, index);
}
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
EnsureTreeView().ContainerForItemPreparedOverride(container, item, index);
}
/// <inheritdoc/>
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{

499
src/Avalonia.Controls/Utils/RealizedStackElements.cs

@ -0,0 +1,499 @@
using System;
using System.Collections.Generic;
using Avalonia.Utilities;
namespace Avalonia.Controls.Utils
{
/// <summary>
/// Stores the realized element state for a virtualizing panel that arranges its children
/// in a stack layout, such as <see cref="VirtualizingStackPanel"/>.
/// </summary>
internal class RealizedStackElements
{
private int _firstIndex;
private List<Control?>? _elements;
private List<double>? _sizes;
private double _startU;
private bool _startUUnstable;
/// <summary>
/// Gets the number of realized elements.
/// </summary>
public int Count => _elements?.Count ?? 0;
/// <summary>
/// Gets the index of the first realized element, or -1 if no elements are realized.
/// </summary>
public int FirstIndex => _elements?.Count > 0 ? _firstIndex : -1;
/// <summary>
/// Gets the index of the last realized element, or -1 if no elements are realized.
/// </summary>
public int LastIndex => _elements?.Count > 0 ? _firstIndex + _elements.Count - 1 : -1;
/// <summary>
/// Gets the elements.
/// </summary>
public IReadOnlyList<Control?> Elements => _elements ??= new List<Control?>();
/// <summary>
/// Gets the sizes of the elements on the primary axis.
/// </summary>
public IReadOnlyList<double> SizeU => _sizes ??= new List<double>();
/// <summary>
/// Gets the position of the first element on the primary axis.
/// </summary>
public double StartU => _startU;
/// <summary>
/// Adds a newly realized element to the collection.
/// </summary>
/// <param name="index">The index of the element.</param>
/// <param name="element">The element.</param>
/// <param name="u">The position of the elemnt on the primary axis.</param>
/// <param name="sizeU">The size of the element on the primary axis.</param>
public void Add(int index, Control element, double u, double sizeU)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
_elements ??= new List<Control?>();
_sizes ??= new List<double>();
if (Count == 0)
{
_elements.Add(element);
_sizes.Add(sizeU);
_startU = u;
_firstIndex = index;
}
else if (index == LastIndex + 1)
{
_elements.Add(element);
_sizes.Add(sizeU);
}
else if (index == FirstIndex - 1)
{
--_firstIndex;
_elements.Insert(0, element);
_sizes.Insert(0, sizeU);
_startU = u;
}
else
{
throw new NotSupportedException("Can only add items to the beginning or end of realized elements.");
}
}
/// <summary>
/// Gets the element at the specified index, if realized.
/// </summary>
/// <param name="index">The index in the source collection of the element to get.</param>
/// <returns>The element if realized; otherwise null.</returns>
public Control? GetElement(int index)
{
var i = index - FirstIndex;
if (i >= 0 && i < _elements?.Count)
return _elements[i];
return null;
}
/// <summary>
/// Gets or estimates the index and start U position of the anchor element for the
/// specified viewport.
/// </summary>
/// <param name="viewportStartU">The U position of the start of the viewport.</param>
/// <param name="viewportEndU">The U position of the end of the viewport.</param>
/// <param name="itemCount">The number of items in the list.</param>
/// <param name="estimatedElementSizeU">The current estimated element size.</param>
/// <returns>
/// A tuple containing:
/// - The index of the anchor element, or -1 if an anchor could not be determined
/// - The U position of the start of the anchor element, if determined
/// </returns>
/// <remarks>
/// This method tries to find an existing element in the specified viewport from which
/// element realization can start. Failing that it estimates the first element in the
/// viewport.
/// </remarks>
public (int index, double position) GetOrEstimateAnchorElementForViewport(
double viewportStartU,
double viewportEndU,
int itemCount,
ref double estimatedElementSizeU)
{
// We have no elements, nothing to do here.
if (itemCount <= 0)
return (-1, 0);
// If we're at 0 then display the first item.
if (MathUtilities.IsZero(viewportStartU))
return (0, 0);
if (_sizes is not null && !_startUUnstable)
{
var u = _startU;
for (var i = 0; i < _sizes.Count; ++i)
{
var size = _sizes[i];
if (double.IsNaN(size))
break;
var endU = u + size;
if (endU > viewportStartU && u < viewportEndU)
return (FirstIndex + i, u);
u = endU;
}
}
// We don't have any realized elements in the requested viewport, or can't rely on
// StartU being valid. Estimate the index using only the estimated size. First,
// estimate the element size, using defaultElementSizeU if we don't have any realized
// elements.
var estimatedSize = EstimateElementSizeU() switch
{
-1 => estimatedElementSizeU,
double v => v,
};
// Store the estimated size for the next layout pass.
estimatedElementSizeU = estimatedSize;
// Estimate the element at the start of the viewport.
var index = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1);
return (index, index * estimatedSize);
}
/// <summary>
/// Gets the position of the element with the requested index on the primary axis, if realized.
/// </summary>
/// <returns>
/// The position of the element, or NaN if the element is not realized.
/// </returns>
public double GetElementU(int index)
{
if (index < FirstIndex || _sizes is null)
return double.NaN;
var endIndex = index - FirstIndex;
if (endIndex >= _sizes.Count)
return double.NaN;
var u = StartU;
for (var i = 0; i < endIndex; ++i)
u += _sizes[i];
return u;
}
public double GetOrEstimateElementU(int index, ref double estimatedElementSizeU)
{
// Return the position of the existing element if realized.
var u = GetElementU(index);
if (!double.IsNaN(u))
return u;
// Estimate the element size, using defaultElementSizeU if we don't have any realized
// elements.
var estimatedSize = EstimateElementSizeU() switch
{
-1 => estimatedElementSizeU,
double v => v,
};
// Store the estimated size for the next layout pass.
estimatedElementSizeU = estimatedSize;
// TODO: Use _startU to work this out.
return index * estimatedSize;
}
/// <summary>
/// Estimates the average U size of all elements in the source collection based on the
/// realized elements.
/// </summary>
/// <returns>
/// The estimated U size of an element, or -1 if not enough information is present to make
/// an estimate.
/// </returns>
public double EstimateElementSizeU()
{
var total = 0.0;
var divisor = 0.0;
// Average the size of the realized elements.
if (_sizes is not null)
{
foreach (var size in _sizes)
{
if (double.IsNaN(size))
continue;
total += size;
++divisor;
}
}
// We don't have any elements on which to base our estimate.
if (divisor == 0 || total == 0)
return -1;
return total / divisor;
}
/// <summary>
/// Gets the index of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <returns>The index or -1 if the element is not present in the collection.</returns>
public int GetIndex(Control element)
{
return _elements?.IndexOf(element) is int index && index >= 0 ? index + FirstIndex : -1;
}
/// <summary>
/// Updates the elements in response to items being inserted into the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the insert.</param>
/// <param name="count">The number of items inserted.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
public void ItemsInserted(int index, int count, Action<Control, int, int> updateElementIndex)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the index within the realized _elements collection.
var first = FirstIndex;
var realizedIndex = index - first;
if (realizedIndex < Count)
{
// The insertion point affects the realized elements. Update the index of the
// elements after the insertion point.
var elementCount = _elements.Count;
var start = Math.Max(realizedIndex, 0);
var newIndex = realizedIndex + count;
for (var i = start; i < elementCount; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
if (realizedIndex < 0)
{
// The insertion point was before the first element, update the first index.
_firstIndex += count;
}
else
{
// The insertion point was within the realized elements, insert an empty space
// in _elements and _sizes.
_elements!.InsertMany(realizedIndex, null, count);
_sizes!.InsertMany(realizedIndex, double.NaN, count);
}
}
}
/// <summary>
/// Updates the elements in response to items being removed from the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the remove.</param>
/// <param name="count">The number of items removed.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsRemoved(
int index,
int count,
Action<Control, int, int> updateElementIndex,
Action<Control> recycleElement)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the removal start and end index within the realized _elements collection.
var first = FirstIndex;
var last = LastIndex;
var startIndex = index - first;
var endIndex = (index + count) - first;
if (endIndex < 0)
{
// The removed range was before the realized elements. Update the first index and
// the indexes of the realized elements.
_firstIndex -= count;
_startUUnstable = true;
var newIndex = _firstIndex;
for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
}
else if (startIndex < _elements.Count)
{
// Recycle and remove the affected elements.
var start = Math.Max(startIndex, 0);
var end = Math.Min(endIndex, _elements.Count);
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
recycleElement(element);
}
_elements.RemoveRange(start, end - start);
_sizes!.RemoveRange(start, end - start);
// If the remove started before and ended within our realized elements, then our new
// first index will be the index where the remove started. Mark StartU as unstable
// because we can't rely on it now to estimate element heights.
if (startIndex <= 0 && end < last)
{
_firstIndex = first = index;
_startUUnstable = true;
}
// Update the indexes of the elements after the removed range.
end = _elements.Count;
var newIndex = first + start;
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex + count, newIndex);
++newIndex;
}
}
}
/// <summary>
/// Recycles all elements in response to the source collection being reset.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsReset(Action<Control> recycleElement)
{
if (_elements is null || _elements.Count == 0)
return;
foreach (var e in _elements)
{
if (e is not null)
recycleElement(e);
}
_startU = _firstIndex = 0;
_elements?.Clear();
_sizes?.Clear();
}
/// <summary>
/// Recycles elements before a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new first element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsBefore(int index, Action<Control, int> recycleElement)
{
if (index <= FirstIndex || _elements is null || _elements.Count == 0)
return;
if (index > LastIndex)
{
RecycleAllElements(recycleElement);
}
else
{
var endIndex = index - FirstIndex;
for (var i = 0; i < endIndex; ++i)
{
if (_elements[i] is Control e)
recycleElement(e, i + FirstIndex);
}
_elements.RemoveRange(0, endIndex);
_sizes!.RemoveRange(0, endIndex);
_firstIndex = index;
}
}
/// <summary>
/// Recycles elements after a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new last element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsAfter(int index, Action<Control, int> recycleElement)
{
if (index >= LastIndex || _elements is null || _elements.Count == 0)
return;
if (index < FirstIndex)
{
RecycleAllElements(recycleElement);
}
else
{
var startIndex = (index + 1) - FirstIndex;
var count = _elements.Count;
for (var i = startIndex; i < count; ++i)
{
if (_elements[i] is Control e)
recycleElement(e, i + FirstIndex);
}
_elements.RemoveRange(startIndex, _elements.Count - startIndex);
_sizes!.RemoveRange(startIndex, _sizes.Count - startIndex);
}
}
/// <summary>
/// Recycles all realized elements.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleAllElements(Action<Control, int> recycleElement)
{
if (_elements is null || _elements.Count == 0)
return;
var i = FirstIndex;
foreach (var e in _elements)
{
if (e is not null)
recycleElement(e, i);
++i;
}
_startU = _firstIndex = 0;
_elements?.Clear();
_sizes?.Clear();
}
/// <summary>
/// Resets the element list and prepares it for reuse.
/// </summary>
public void ResetForReuse()
{
_startU = _firstIndex = 0;
_startUUnstable = false;
_elements?.Clear();
_sizes?.Clear();
}
}
}

666
src/Avalonia.Controls/VirtualizingStackPanel.cs

@ -60,13 +60,14 @@ namespace Avalonia.Controls
private readonly Action<Control, int> _recycleElement;
private readonly Action<Control> _recycleElementOnItemRemoved;
private readonly Action<Control, int, int> _updateElementIndex;
private int _anchorIndex = -1;
private Control? _anchorElement;
private int _scrollToIndex = -1;
private Control? _scrollToElement;
private bool _isInLayout;
private bool _isWaitingForViewportUpdate;
private double _lastEstimatedElementSizeU = 25;
private RealizedElementList? _measureElements;
private RealizedElementList? _realizedElements;
private RealizedStackElements? _measureElements;
private RealizedStackElements? _realizedElements;
private ScrollViewer? _scrollViewer;
private Rect _viewport = s_invalidViewport;
private Stack<Control>? _recyclePool;
private Control? _unrealizedFocusedElement;
@ -129,50 +130,55 @@ namespace Avalonia.Controls
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets the index of the first realized element, or -1 if no elements are realized.
/// </summary>
public int FirstRealizedIndex => _realizedElements?.FirstIndex ?? -1;
/// <summary>
/// Gets the index of the last realized element, or -1 if no elements are realized.
/// </summary>
public int LastRealizedIndex => _realizedElements?.LastIndex ?? -1;
protected override Size MeasureOverride(Size availableSize)
{
if (!IsEffectivelyVisible)
var items = Items;
if (items.Count == 0)
return default;
// If we're bringing an item into view, ignore any layout passes until we receive a new
// effective viewport.
if (_isWaitingForViewportUpdate)
return DesiredSize;
_isInLayout = true;
try
{
var items = Items;
var orientation = Orientation;
_realizedElements ??= new();
_measureElements ??= new();
// If we're bringing an item into view, ignore any layout passes until we receive a new
// effective viewport.
if (_isWaitingForViewportUpdate)
{
var sizeV = orientation == Orientation.Horizontal ? DesiredSize.Height : DesiredSize.Width;
return CalculateDesiredSize(orientation, items, sizeV);
}
// We handle horizontal and vertical layouts here so X and Y are abstracted to:
// - Horizontal layouts: U = horizontal, V = vertical
// - Vertical layouts: U = vertical, V = horizontal
var viewport = CalculateMeasureViewport(items);
// Recycle elements outside of the expected range.
_realizedElements.RecycleElementsBefore(viewport.firstIndex, _recycleElement);
_realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement);
// If the viewport is disjunct then we can recycle everything.
if (viewport.viewportIsDisjunct)
_realizedElements.RecycleAllElements(_recycleElement);
// Do the measure, creating/recycling elements as necessary to fill the viewport. Don't
// write to _realizedElements yet, only _measureElements.
GenerateElements(availableSize, ref viewport);
// Now we know what definitely fits, recycle anything left over.
_realizedElements.RecycleElementsAfter(_measureElements.LastIndex, _recycleElement);
RealizeElements(items, availableSize, ref viewport);
// And swap the measureElements and realizedElements collection.
// Now swap the measureElements and realizedElements collection.
(_measureElements, _realizedElements) = (_realizedElements, _measureElements);
_measureElements.ResetForReuse();
return CalculateDesiredSize(orientation, items, viewport.measuredV);
return CalculateDesiredSize(orientation, items.Count, viewport);
}
finally
{
@ -203,6 +209,7 @@ namespace Avalonia.Controls
new Rect(u, 0, sizeU, finalSize.Height) :
new Rect(0, u, finalSize.Width, sizeU);
e.Arrange(rect);
_scrollViewer?.RegisterAnchorCandidate(e);
u += orientation == Orientation.Horizontal ? rect.Width : rect.Height;
}
}
@ -217,6 +224,18 @@ namespace Avalonia.Controls
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_scrollViewer = this.FindAncestorOfType<ScrollViewer>();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_scrollViewer = null;
}
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
InvalidateMeasure();
@ -314,7 +333,7 @@ namespace Avalonia.Controls
{
var items = Items;
if (_isInLayout || index < 0 || index >= items.Count)
if (_isInLayout || index < 0 || index >= items.Count || _realizedElements is null)
return null;
if (GetRealizedElement(index) is Control element)
@ -326,16 +345,16 @@ namespace Avalonia.Controls
{
// Create and measure the element to be brought into view. Store it in a field so that
// it can be re-used in the layout pass.
_anchorElement = GetOrCreateElement(items, index);
_anchorElement.Measure(Size.Infinity);
_anchorIndex = index;
_scrollToElement = GetOrCreateElement(items, index);
_scrollToElement.Measure(Size.Infinity);
_scrollToIndex = index;
// Get the expected position of the elment and put it in place.
var anchorU = GetOrEstimateElementPosition(index);
var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU);
var rect = Orientation == Orientation.Horizontal ?
new Rect(anchorU, 0, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height) :
new Rect(0, anchorU, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height);
_anchorElement.Arrange(rect);
new Rect(anchorU, 0, _scrollToElement.DesiredSize.Width, _scrollToElement.DesiredSize.Height) :
new Rect(0, anchorU, _scrollToElement.DesiredSize.Width, _scrollToElement.DesiredSize.Height);
_scrollToElement.Arrange(rect);
// If the item being brought into view was added since the last layout pass then
// our bounds won't be updated, so any containing scroll viewers will not have an
@ -349,7 +368,7 @@ namespace Avalonia.Controls
}
// Try to bring the item into view.
_anchorElement.BringIntoView();
_scrollToElement.BringIntoView();
// If the viewport does not contain the item to scroll to, set _isWaitingForViewportUpdate:
// this should cause the following chain of events:
@ -368,9 +387,9 @@ namespace Avalonia.Controls
root.LayoutManager.ExecuteLayoutPass();
}
var result = _anchorElement;
_anchorElement = null;
_anchorIndex = -1;
var result = _scrollToElement;
_scrollToElement = null;
_scrollToIndex = -1;
return result;
}
@ -394,46 +413,40 @@ namespace Avalonia.Controls
var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom;
var (firstIndex, firstIndexU) = _realizedElements.GetIndexAt(viewportStart);
var (lastIndex, _) = _realizedElements.GetIndexAt(viewportEnd);
var estimatedElementSize = -1.0;
// Get or estimate the anchor element from which to start realization.
var itemCount = items?.Count ?? 0;
var maxIndex = Math.Max(itemCount - 1, 0);
if (firstIndex == -1)
{
estimatedElementSize = EstimateElementSizeU();
firstIndex = Math.Min((int)(viewportStart / estimatedElementSize), maxIndex);
firstIndexU = firstIndex * estimatedElementSize;
}
var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport(
viewportStart,
viewportEnd,
itemCount,
ref _lastEstimatedElementSizeU);
if (lastIndex == -1)
{
if (estimatedElementSize == -1)
estimatedElementSize = EstimateElementSizeU();
lastIndex = Math.Min((int)(viewportEnd / estimatedElementSize), maxIndex);
}
// Check if the anchor element is not within the currently realized elements.
var disjunct = anchorIndex < _realizedElements.FirstIndex ||
anchorIndex > _realizedElements.LastIndex;
return new MeasureViewport
{
firstIndex = firstIndex,
lastIndex = lastIndex,
anchorIndex = anchorIndex,
anchorU = anchorU,
viewportUStart = viewportStart,
viewportUEnd = viewportEnd,
startU = firstIndexU,
viewportIsDisjunct = disjunct,
};
}
private Size CalculateDesiredSize(Orientation orientation, IReadOnlyList<object?> items, double sizeV)
private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport)
{
var sizeU = EstimateElementSizeU() * items.Count;
var sizeU = 0.0;
var sizeV = viewport.measuredV;
if (double.IsInfinity(sizeU) || double.IsNaN(sizeU))
throw new InvalidOperationException("Invalid calculated size.");
if (viewport.lastIndex >= 0)
{
var remaining = itemCount - viewport.lastIndex - 1;
sizeU = viewport.realizedEndU + (remaining * _lastEstimatedElementSizeU);
}
return orientation == Orientation.Horizontal ?
new Size(sizeU, sizeV) :
new Size(sizeV, sizeU);
return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU);
}
private double EstimateElementSizeU()
@ -474,19 +487,25 @@ namespace Avalonia.Controls
return viewport;
}
private void GenerateElements(Size availableSize, ref MeasureViewport viewport)
private void RealizeElements(
IReadOnlyList<object?> items,
Size availableSize,
ref MeasureViewport viewport)
{
Debug.Assert(_measureElements is not null);
Debug.Assert(_realizedElements is not null);
Debug.Assert(items.Count > 0);
var items = Items;
var index = viewport.anchorIndex;
var horizontal = Orientation == Orientation.Horizontal;
var index = viewport.firstIndex;
var u = viewport.startU;
var u = viewport.anchorU;
// The layout is likely invalid. Don't create any elements and instead rely on our previous
// element size estimates to calculate a new desired size and trigger a new layout pass.
if (index >= items.Count)
return;
// If the anchor element is at the beginning of, or before, the start of the viewport
// then we can recycle all elements before it.
if (u <= viewport.anchorU)
_realizedElements.RecycleElementsBefore(viewport.anchorIndex, _recycleElement);
// Start at the anchor element and move forwards, realizing elements.
do
{
var e = GetOrCreateElement(items, index);
@ -501,6 +520,34 @@ namespace Avalonia.Controls
u += sizeU;
++index;
} while (u < viewport.viewportUEnd && index < items.Count);
// Store the last index and end U position for the desired size calculation.
viewport.lastIndex = index - 1;
viewport.realizedEndU = u;
// We can now recycle elements after the last element.
_realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement);
// Next move backwards from the anchor element, realizing elements.
index = viewport.anchorIndex - 1;
u = viewport.anchorU;
while (u > viewport.viewportUStart && index >= 0)
{
var e = GetOrCreateElement(items, index);
e.Measure(availableSize);
var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
u -= sizeU;
_measureElements!.Add(index, e, u, sizeU);
viewport.measuredV = Math.Max(viewport.measuredV, sizeV);
--index;
}
// We can now recycle elements before the first element.
_realizedElements.RecycleElementsBefore(index + 1, _recycleElement);
}
private Control GetOrCreateElement(IReadOnlyList<object?> items, int index)
@ -515,8 +562,8 @@ namespace Avalonia.Controls
private Control? GetRealizedElement(int index)
{
if (_anchorIndex == index)
return _anchorElement;
if (_scrollToIndex == index)
return _scrollToElement;
return _realizedElements?.GetElement(index);
}
@ -554,13 +601,15 @@ namespace Avalonia.Controls
var generator = ItemContainerGenerator!;
var item = items[index];
if (_unrealizedFocusedIndex == index)
if (_unrealizedFocusedIndex == index && _unrealizedFocusedElement is not null)
{
var element = _unrealizedFocusedElement;
_unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
_unrealizedFocusedElement = null;
_unrealizedFocusedIndex = -1;
return element;
}
if (_recyclePool?.Count > 0)
{
var recycled = _recyclePool.Pop();
@ -588,16 +637,12 @@ namespace Avalonia.Controls
return container;
}
private double GetOrEstimateElementPosition(int index)
{
var estimatedElementSize = EstimateElementSizeU();
return index * estimatedElementSize;
}
private void RecycleElement(Control element, int index)
{
Debug.Assert(ItemContainerGenerator is not null);
_scrollViewer?.UnregisterAnchorCandidate(element);
if (element.IsSet(ItemIsOwnContainerProperty))
{
element.IsVisible = false;
@ -606,6 +651,7 @@ namespace Avalonia.Controls
{
_unrealizedFocusedElement = element;
_unrealizedFocusedIndex = index;
_unrealizedFocusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
}
else
{
@ -646,7 +692,7 @@ namespace Avalonia.Controls
var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
_viewport = e.EffectiveViewport;
_viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
_isWaitingForViewportUpdate = false;
var newViewportStart = vertical ? _viewport.Top : _viewport.Left;
@ -692,6 +738,17 @@ namespace Avalonia.Controls
Invalidate(c);
}
private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e)
{
if (_unrealizedFocusedElement is null || sender != _unrealizedFocusedElement)
return;
_unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
RecycleElement(_unrealizedFocusedElement, _unrealizedFocusedIndex);
_unrealizedFocusedElement = null;
_unrealizedFocusedIndex = -1;
}
/// <inheritdoc/>
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
@ -853,455 +910,16 @@ namespace Avalonia.Controls
return snapPoint;
}
/// <summary>
/// Stores the realized element state for a <see cref="VirtualizingStackPanel"/>.
/// </summary>
internal class RealizedElementList
{
private int _firstIndex;
private List<Control?>? _elements;
private List<double>? _sizes;
private double _startU;
private bool _startUUnstable;
/// <summary>
/// Gets the number of realized elements.
/// </summary>
public int Count => _elements?.Count ?? 0;
/// <summary>
/// Gets the index of the first realized element, or -1 if no elements are realized.
/// </summary>
public int FirstIndex => _elements?.Count > 0 ? _firstIndex : -1;
/// <summary>
/// Gets the index of the last realized element, or -1 if no elements are realized.
/// </summary>
public int LastIndex => _elements?.Count > 0 ? _firstIndex + _elements.Count - 1 : -1;
/// <summary>
/// Gets the elements.
/// </summary>
public IReadOnlyList<Control?> Elements => _elements ??= new List<Control?>();
/// <summary>
/// Gets the sizes of the elements on the primary axis.
/// </summary>
public IReadOnlyList<double> SizeU => _sizes ??= new List<double>();
/// <summary>
/// Gets the position of the first element on the primary axis.
/// </summary>
public double StartU => _startU;
/// <summary>
/// Adds a newly realized element to the collection.
/// </summary>
/// <param name="index">The index of the element.</param>
/// <param name="element">The element.</param>
/// <param name="u">The position of the elemnt on the primary axis.</param>
/// <param name="sizeU">The size of the element on the primary axis.</param>
public void Add(int index, Control element, double u, double sizeU)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
_elements ??= new List<Control?>();
_sizes ??= new List<double>();
if (Count == 0)
{
_elements.Add(element);
_sizes.Add(sizeU);
_startU = u;
_firstIndex = index;
}
else if (index == LastIndex + 1)
{
_elements.Add(element);
_sizes.Add(sizeU);
}
else if (index == FirstIndex - 1)
{
--_firstIndex;
_elements.Insert(0, element);
_sizes.Insert(0, sizeU);
_startU = u;
}
else
{
throw new NotSupportedException("Can only add items to the beginning or end of realized elements.");
}
}
/// <summary>
/// Gets the element at the specified index, if realized.
/// </summary>
/// <param name="index">The index in the source collection of the element to get.</param>
/// <returns>The element if realized; otherwise null.</returns>
public Control? GetElement(int index)
{
var i = index - FirstIndex;
if (i >= 0 && i < _elements?.Count)
return _elements[i];
return null;
}
/// <summary>
/// Gets the index and start U position of the element at the specified U position.
/// </summary>
/// <param name="u">The U position.</param>
/// <returns>
/// A tuple containing:
/// - The index of the item at the specified U position, or -1 if the item could not be
/// determined
/// - The U position of the start of the item, if determined
/// </returns>
public (int index, double position) GetIndexAt(double u)
{
if (_elements is null || _sizes is null || _startU > u || _startUUnstable)
return (-1, 0);
var index = 0;
var position = _startU;
while (index < _elements.Count)
{
var size = _sizes[index];
if (double.IsNaN(size))
break;
if (u >= position && u < position + size)
return (index + FirstIndex, position);
position += size;
++index;
}
return (-1, 0);
}
/// <summary>
/// Gets the element at the specified position on the primary axis, if realized.
/// </summary>
/// <param name="position">The position.</param>
/// <returns>
/// A tuple containing the index of the element (or -1 if not found) and the position of the element on the
/// primary axis.
/// </returns>
public (int index, double position) GetElementAt(double position)
{
if (_sizes is null || position < StartU)
return (-1, 0);
var u = StartU;
var i = FirstIndex;
foreach (var size in _sizes)
{
var endU = u + size;
if (position < endU)
return (i, u);
u += size;
++i;
}
return (-1, 0);
}
/// <summary>
/// Estimates the average U size of all elements in the source collection based on the
/// realized elements.
/// </summary>
/// <returns>
/// The estimated U size of an element, or -1 if not enough information is present to make
/// an estimate.
/// </returns>
public double EstimateElementSizeU()
{
var total = 0.0;
var divisor = 0.0;
// Start by averaging the size of the elements before the first realized element.
if (FirstIndex >= 0 && !_startUUnstable)
{
total += _startU;
divisor += FirstIndex;
}
// Average the size of the realized elements.
if (_sizes is not null)
{
foreach (var size in _sizes)
{
if (double.IsNaN(size))
continue;
total += size;
++divisor;
}
}
// We don't have any elements on which to base our estimate.
if (divisor == 0 || total == 0)
return -1;
return total / divisor;
}
/// <summary>
/// Gets the index of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <returns>The index or -1 if the element is not present in the collection.</returns>
public int GetIndex(Control element)
{
return _elements?.IndexOf(element) is int index && index >= 0 ? index + FirstIndex : -1;
}
/// <summary>
/// Updates the elements in response to items being inserted into the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the insert.</param>
/// <param name="count">The number of items inserted.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
public void ItemsInserted(int index, int count, Action<Control, int, int> updateElementIndex)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the index within the realized _elements collection.
var first = FirstIndex;
var realizedIndex = index - first;
if (realizedIndex < Count)
{
// The insertion point affects the realized elements. Update the index of the
// elements after the insertion point.
var elementCount = _elements.Count;
var start = Math.Max(realizedIndex, 0);
var newIndex = realizedIndex + count;
for (var i = start; i < elementCount; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
if (realizedIndex < 0)
{
// The insertion point was before the first element, update the first index.
_firstIndex += count;
}
else
{
// The insertion point was within the realized elements, insert an empty space
// in _elements and _sizes.
_elements!.InsertMany(realizedIndex, null, count);
_sizes!.InsertMany(realizedIndex, double.NaN, count);
}
}
}
/// <summary>
/// Updates the elements in response to items being removed from the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the remove.</param>
/// <param name="count">The number of items removed.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsRemoved(
int index,
int count,
Action<Control, int, int> updateElementIndex,
Action<Control> recycleElement)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the removal start and end index within the realized _elements collection.
var first = FirstIndex;
var last = LastIndex;
var startIndex = index - first;
var endIndex = (index + count) - first;
if (endIndex < 0)
{
// The removed range was before the realized elements. Update the first index and
// the indexes of the realized elements.
_firstIndex -= count;
_startUUnstable = true;
var newIndex = _firstIndex;
for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
}
else if (startIndex < _elements.Count)
{
// Recycle and remove the affected elements.
var start = Math.Max(startIndex, 0);
var end = Math.Min(endIndex, _elements.Count);
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
recycleElement(element);
}
_elements.RemoveRange(start, end - start);
_sizes!.RemoveRange(start, end - start);
// If the remove started before and ended within our realized elements, then our new
// first index will be the index where the remove started. Mark StartU as unstable
// because we can't rely on it now to estimate element heights.
if (startIndex <= 0 && end < last)
{
_firstIndex = first = index;
_startUUnstable = true;
}
// Update the indexes of the elements after the removed range.
end = _elements.Count;
var newIndex = first + start;
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex + count, newIndex);
++newIndex;
}
}
}
/// <summary>
/// Recycles all elements in response to the source collection being reset.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsReset(Action<Control> recycleElement)
{
if (_elements is null || _elements.Count == 0)
return;
foreach (var e in _elements)
{
if (e is not null)
recycleElement(e);
}
_startU = _firstIndex = 0;
_elements?.Clear();
_sizes?.Clear();
}
/// <summary>
/// Recycles elements before a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new first element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsBefore(int index, Action<Control, int> recycleElement)
{
if (index <= FirstIndex || _elements is null || _elements.Count == 0)
return;
if (index > LastIndex)
{
RecycleAllElements(recycleElement);
}
else
{
var endIndex = index - FirstIndex;
for (var i = 0; i < endIndex; ++i)
{
if (_elements[i] is Control e)
recycleElement(e, i + FirstIndex);
}
_elements.RemoveRange(0, endIndex);
_sizes!.RemoveRange(0, endIndex);
_firstIndex = index;
}
}
/// <summary>
/// Recycles elements after a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new last element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsAfter(int index, Action<Control, int> recycleElement)
{
if (index >= LastIndex || _elements is null || _elements.Count == 0)
return;
if (index < FirstIndex)
{
RecycleAllElements(recycleElement);
}
else
{
var startIndex = (index + 1) - FirstIndex;
var count = _elements.Count;
for (var i = startIndex; i < count; ++i)
{
if (_elements[i] is Control e)
recycleElement(e, i + FirstIndex);
}
_elements.RemoveRange(startIndex, _elements.Count - startIndex);
_sizes!.RemoveRange(startIndex, _sizes.Count - startIndex);
}
}
/// <summary>
/// Recycles all realized elements.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleAllElements(Action<Control, int> recycleElement)
{
if (_elements is null || _elements.Count == 0)
return;
var i = FirstIndex;
foreach (var e in _elements)
{
if (e is not null)
recycleElement(e, i);
++i;
}
_startU = _firstIndex = 0;
_elements?.Clear();
_sizes?.Clear();
}
/// <summary>
/// Resets the element list and prepares it for reuse.
/// </summary>
public void ResetForReuse()
{
_startU = _firstIndex = 0;
_startUUnstable = false;
_elements?.Clear();
_sizes?.Clear();
}
}
private struct MeasureViewport
{
public int firstIndex;
public int lastIndex;
public int anchorIndex;
public double anchorU;
public double viewportUStart;
public double viewportUEnd;
public double measuredV;
public double startU;
public double realizedEndU;
public int lastIndex;
public bool viewportIsDisjunct;
}
}
}

2
src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs

@ -43,6 +43,8 @@ namespace Avalonia.Headless
_framesPerSecond = framesPerSecond;
}
public override bool RunsInBackground => false;
public void ForceTick() => _forceTick?.Invoke();
}

3
src/Avalonia.Headless/HeadlessPlatformStubs.cs

@ -179,8 +179,7 @@ namespace Avalonia.Headless
return true;
}
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
FontFamily fontFamily, CultureInfo culture, out Typeface typeface)
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo culture, out Typeface typeface)
{
typeface = new Typeface("Arial", fontStyle, fontWeight, fontStretch);
return true;

8
src/Avalonia.X11/Glx/GlxContext.cs

@ -106,8 +106,8 @@ namespace Avalonia.X11.Glx
var success = false;
try
{
var old = new RestoreContext(Glx, _x11.Display, _lock);
if (!Glx.MakeContextCurrent(_x11.Display, xid, xid, Handle))
var old = new RestoreContext(Glx, _x11.DeferredDisplay, _lock);
if (!Glx.MakeContextCurrent(_x11.DeferredDisplay, xid, xid, Handle))
throw new OpenGlException("glXMakeContextCurrent failed ");
success = true;
@ -124,9 +124,9 @@ namespace Avalonia.X11.Glx
public void Dispose()
{
Glx.DestroyContext(_x11.Display, Handle);
Glx.DestroyContext(_x11.DeferredDisplay, Handle);
if (_ownsPBuffer)
Glx.DestroyPbuffer(_x11.Display, _defaultXid);
Glx.DestroyPbuffer(_x11.DeferredDisplay, _defaultXid);
}
public object TryGetFeature(Type featureType)

22
src/Avalonia.X11/Glx/GlxDisplay.cs

@ -22,7 +22,7 @@ namespace Avalonia.X11.Glx
{
_x11 = x11;
_probeProfiles = probeProfiles.ToArray();
_displayExtensions = Glx.GetExtensions(_x11.Display);
_displayExtensions = Glx.GetExtensions(_x11.DeferredDisplay);
var baseAttribs = new[]
{
@ -46,12 +46,12 @@ namespace Avalonia.X11.Glx
baseAttribs,
})
{
var ptr = Glx.ChooseFBConfig(_x11.Display, x11.DefaultScreen,
var ptr = Glx.ChooseFBConfig(_x11.DeferredDisplay, x11.DefaultScreen,
attribs, out var count);
for (var c = 0 ; c < count; c++)
{
var visual = Glx.GetVisualFromFBConfig(_x11.Display, ptr[c]);
var visual = Glx.GetVisualFromFBConfig(_x11.DeferredDisplay, ptr[c]);
// We prefer 32 bit visuals
if (_fbconfig == IntPtr.Zero || visual->depth == 32)
{
@ -71,17 +71,17 @@ namespace Avalonia.X11.Glx
if (_visual == null)
throw new OpenGlException("Unable to get visual info from FBConfig");
if (Glx.GetFBConfigAttrib(_x11.Display, _fbconfig, GLX_SAMPLES, out var samples) == 0)
if (Glx.GetFBConfigAttrib(_x11.DeferredDisplay, _fbconfig, GLX_SAMPLES, out var samples) == 0)
sampleCount = samples;
if (Glx.GetFBConfigAttrib(_x11.Display, _fbconfig, GLX_STENCIL_SIZE, out var stencil) == 0)
if (Glx.GetFBConfigAttrib(_x11.DeferredDisplay, _fbconfig, GLX_STENCIL_SIZE, out var stencil) == 0)
stencilSize = stencil;
var attributes = new[] { GLX_PBUFFER_WIDTH, 1, GLX_PBUFFER_HEIGHT, 1, 0 };
Glx.CreatePbuffer(_x11.Display, _fbconfig, attributes);
Glx.CreatePbuffer(_x11.Display, _fbconfig, attributes);
Glx.CreatePbuffer(_x11.DeferredDisplay, _fbconfig, attributes);
Glx.CreatePbuffer(_x11.DeferredDisplay, _fbconfig, attributes);
XLib.XFlush(_x11.Display);
XLib.XFlush(_x11.DeferredDisplay);
DeferredContext = CreateContext(CreatePBuffer(), null,
sampleCount, stencilSize, true);
@ -108,7 +108,7 @@ namespace Avalonia.X11.Glx
private IntPtr CreatePBuffer()
{
return Glx.CreatePbuffer(_x11.Display, _fbconfig, new[] { GLX_PBUFFER_WIDTH, 1, GLX_PBUFFER_HEIGHT, 1, 0 });
return Glx.CreatePbuffer(_x11.DeferredDisplay, _fbconfig, new[] { GLX_PBUFFER_WIDTH, 1, GLX_PBUFFER_HEIGHT, 1, 0 });
}
public GlxContext CreateContext() => CreateContext(CreatePBuffer(), null, DeferredContext.SampleCount,
@ -139,7 +139,7 @@ namespace Avalonia.X11.Glx
try
{
handle = Glx.CreateContextAttribsARB(_x11.Display, _fbconfig, sharelist, true, attrs);
handle = Glx.CreateContextAttribsARB(_x11.DeferredDisplay, _fbconfig, sharelist, true, attrs);
if (handle != IntPtr.Zero)
{
_version = profile;
@ -181,6 +181,6 @@ namespace Avalonia.X11.Glx
throw new OpenGlException("Unable to create direct GLX context");
}
public void SwapBuffers(IntPtr xid) => Glx.SwapBuffers(_x11.Display, xid);
public void SwapBuffers(IntPtr xid) => Glx.SwapBuffers(_x11.DeferredDisplay, xid);
}
}

5
src/Avalonia.X11/X11Platform.cs

@ -50,9 +50,8 @@ namespace Avalonia.X11
useXim = true;
}
// XIM doesn't work at all otherwise
if (useXim)
setlocale(0, "");
// We have problems with text input otherwise
setlocale(0, "");
XInitThreads();
Display = XOpenDisplay(IntPtr.Zero);

2
src/Avalonia.X11/X11Window.Ime.cs

@ -89,7 +89,7 @@ namespace Avalonia.X11
}
}
private void UpdateImePosition() => _imeControl?.UpdateWindowInfo(Position, RenderScaling);
private void UpdateImePosition() => _imeControl?.UpdateWindowInfo(_position ?? default, RenderScaling);
private void HandleKeyEvent(ref XEvent ev)
{

91
src/Avalonia.X11/X11Window.cs

@ -48,6 +48,7 @@ namespace Avalonia.X11
private readonly X11NativeControlHost _nativeControlHost;
private PixelPoint? _position;
private PixelSize _realSize;
private bool _cleaningUp;
private IntPtr _handle;
private IntPtr _xic;
private IntPtr _renderHandle;
@ -124,7 +125,7 @@ namespace Avalonia.X11
if (!_popup && Screen != null)
{
var monitor = Screen.AllScreens.OrderBy(x => x.Scaling)
.FirstOrDefault(m => m.Bounds.Contains(Position));
.FirstOrDefault(m => m.Bounds.Contains(_position ?? default));
if (monitor != null)
{
@ -183,7 +184,7 @@ namespace Avalonia.X11
surfaces.Insert(0,
new EglGlPlatformSurface(new SurfaceInfo(this, _x11.DeferredDisplay, _handle, _renderHandle)));
if (glx != null)
surfaces.Insert(0, new GlxGlPlatformSurface(new SurfaceInfo(this, _x11.Display, _handle, _renderHandle)));
surfaces.Insert(0, new GlxGlPlatformSurface(new SurfaceInfo(this, _x11.DeferredDisplay, _handle, _renderHandle)));
surfaces.Add(Handle);
@ -326,23 +327,16 @@ namespace Avalonia.X11
{
get
{
XGetWindowProperty(_x11.Display, _handle, _x11.Atoms._NET_FRAME_EXTENTS, IntPtr.Zero,
new IntPtr(4), false, (IntPtr)Atom.AnyPropertyType, out var _,
out var _, out var nitems, out var _, out var prop);
var extents = GetFrameExtents();
if (nitems.ToInt64() != 4)
if(extents == null)
{
// Window hasn't been mapped by the WM yet, so can't get the extents.
return null;
}
var data = (IntPtr*)prop.ToPointer();
var extents = new Thickness(data[0].ToInt32(), data[2].ToInt32(), data[1].ToInt32(), data[3].ToInt32());
XFree(prop);
return new Size(
(_realSize.Width + extents.Left + extents.Right) / RenderScaling,
(_realSize.Height + extents.Top + extents.Bottom) / RenderScaling);
(_realSize.Width + extents.Value.Left + extents.Value.Right) / RenderScaling,
(_realSize.Height + extents.Value.Top + extents.Value.Bottom) / RenderScaling);
}
}
@ -529,7 +523,7 @@ namespace Avalonia.X11
else if (ev.type == XEventName.DestroyNotify
&& ev.DestroyWindowEvent.window == _handle)
{
Cleanup();
Cleanup(true);
}
else if (ev.type == XEventName.ClientMessage)
{
@ -556,6 +550,25 @@ namespace Avalonia.X11
}
}
private Thickness? GetFrameExtents()
{
XGetWindowProperty(_x11.Display, _handle, _x11.Atoms._NET_FRAME_EXTENTS, IntPtr.Zero,
new IntPtr(4), false, (IntPtr)Atom.AnyPropertyType, out var _,
out var _, out var nitems, out var _, out var prop);
if (nitems.ToInt64() != 4)
{
// Window hasn't been mapped by the WM yet, so can't get the extents.
return null;
}
var data = (IntPtr*)prop.ToPointer();
var extents = new Thickness(data[0].ToInt32(), data[2].ToInt32(), data[1].ToInt32(), data[3].ToInt32());
XFree(prop);
return extents;
}
private bool UpdateScaling(bool skipResize = false)
{
double newScaling;
@ -564,7 +577,7 @@ namespace Avalonia.X11
else
{
var monitor = _platform.X11Screens.Screens.OrderBy(x => x.Scaling)
.FirstOrDefault(m => m.Bounds.Contains(Position));
.FirstOrDefault(m => m.Bounds.Contains(_position ?? default));
newScaling = monitor?.Scaling ?? RenderScaling;
}
@ -804,7 +817,7 @@ namespace Avalonia.X11
public void Dispose()
{
Cleanup();
Cleanup(false);
}
public virtual object? TryGetFeature(Type featureType)
@ -837,8 +850,17 @@ namespace Avalonia.X11
return null;
}
private void Cleanup()
private void Cleanup(bool fromDestroyNotification)
{
// Prevent reentrancy
if(_cleaningUp)
return;
_cleaningUp = true;
// Before doing anything else notify the TopLevel that ITopLevelImpl is no longer valid
if (_handle != IntPtr.Zero)
Closed?.Invoke();
if (_rawEventGrouper != null)
{
_rawEventGrouper.Dispose();
@ -876,10 +898,10 @@ namespace Avalonia.X11
_platform.XI2?.OnWindowDestroyed(_handle);
var handle = _handle;
_handle = IntPtr.Zero;
Closed?.Invoke();
_mouse.Dispose();
_touch.Dispose();
XDestroyWindow(_x11.Display, handle);
if (!fromDestroyNotification)
XDestroyWindow(_x11.Display, handle);
}
if (_useRenderWindow && _renderHandle != IntPtr.Zero)
@ -916,11 +938,11 @@ namespace Avalonia.X11
public void Hide() => XUnmapWindow(_x11.Display, _handle);
public Point PointToClient(PixelPoint point) => new Point((point.X - Position.X) / RenderScaling, (point.Y - Position.Y) / RenderScaling);
public Point PointToClient(PixelPoint point) => new Point((point.X - (_position ?? default).X) / RenderScaling, (point.Y - (_position ?? default).Y) / RenderScaling);
public PixelPoint PointToScreen(Point point) => new PixelPoint(
(int)(point.X * RenderScaling + Position.X),
(int)(point.Y * RenderScaling + Position.Y));
(int)(point.X * RenderScaling + (_position ?? default).X),
(int)(point.Y * RenderScaling + (_position ?? default).Y));
public void SetSystemDecorations(SystemDecorations enabled)
{
@ -984,20 +1006,36 @@ namespace Avalonia.X11
public PixelPoint Position
{
get => _position ?? default;
get
{
if(_position == null)
{
return default;
}
var extents = GetFrameExtents();
if(extents == null)
{
extents = default(Thickness);
}
return new PixelPoint(_position.Value.X - (int)extents.Value.Left, _position.Value.Y - (int)extents.Value.Top);
}
set
{
if(!_usePositioningFlags)
if (!_usePositioningFlags)
{
_usePositioningFlags = true;
UpdateSizeHints(null);
}
var changes = new XWindowChanges
{
x = (int)value.X,
x = value.X,
y = (int)value.Y
};
XConfigureWindow(_x11.Display, _handle, ChangeWindowFlags.CWX | ChangeWindowFlags.CWY,
ref changes);
XFlush(_x11.Display);
@ -1006,7 +1044,6 @@ namespace Avalonia.X11
_position = value;
PositionChanged?.Invoke(value);
}
}
}

41
src/Skia/Avalonia.Skia/FontManagerImpl.cs

@ -30,8 +30,7 @@ namespace Avalonia.Skia
[ThreadStatic] private static string[]? t_languageTagBuffer;
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle,
FontWeight fontWeight, FontStretch fontStretch,
FontFamily? fontFamily, CultureInfo? culture, out Typeface fontKey)
FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface fontKey)
{
SKFontStyle skFontStyle;
@ -60,35 +59,13 @@ namespace Avalonia.Skia
t_languageTagBuffer[0] = culture.TwoLetterISOLanguageName;
t_languageTagBuffer[1] = culture.ThreeLetterISOLanguageName;
if (fontFamily is not null && fontFamily.FamilyNames.HasFallbacks)
{
var familyNames = fontFamily.FamilyNames;
for (var i = 1; i < familyNames.Count; i++)
{
var skTypeface =
_skFontManager.MatchCharacter(familyNames[i], skFontStyle, t_languageTagBuffer, codepoint);
if (skTypeface == null)
{
continue;
}
var skTypeface = _skFontManager.MatchCharacter(null, skFontStyle, t_languageTagBuffer, codepoint);
fontKey = new Typeface(skTypeface.FamilyName, fontStyle, fontWeight, fontStretch);
return true;
}
}
else
if (skTypeface != null)
{
var skTypeface = _skFontManager.MatchCharacter(null, skFontStyle, t_languageTagBuffer, codepoint);
fontKey = new Typeface(skTypeface.FamilyName, fontStyle, fontWeight, fontStretch);
if (skTypeface != null)
{
fontKey = new Typeface(skTypeface.FamilyName, fontStyle, fontWeight, fontStretch);
return true;
}
return true;
}
fontKey = default;
@ -96,7 +73,7 @@ namespace Avalonia.Skia
return false;
}
public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface)
{
glyphTypeface = null;
@ -111,12 +88,6 @@ namespace Avalonia.Skia
return false;
}
//MatchFamily can return a font other than we requested so we have to verify we got the expected.
if (!skTypeface.FamilyName.ToLower(CultureInfo.InvariantCulture).Equals(familyName.ToLower(CultureInfo.InvariantCulture), StringComparison.Ordinal))
{
return false;
}
var fontSimulations = FontSimulations.None;
if ((int)weight >= 600 && !skTypeface.IsBold)

3
src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs

@ -33,8 +33,7 @@ namespace Avalonia.Direct2D1.Media
}
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle,
FontWeight fontWeight, FontStretch fontStretch,
FontFamily fontFamily, CultureInfo culture, out Typeface typeface)
FontWeight fontWeight, FontStretch fontStretch, CultureInfo culture, out Typeface typeface)
{
var familyCount = Direct2D1FontCollectionCache.InstalledFontCollection.FontFamilyCount;

16
src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs

@ -85,6 +85,9 @@ namespace Avalonia.Win32
case WindowsMessage.WM_DESTROY:
{
// The first and foremost thing to do - notify the TopLevel
Closed?.Invoke();
if (UiaCoreTypesApi.IsNetComInteropAvailable)
{
UiaCoreProviderApi.UiaReturnRawElementProvider(_hwnd, IntPtr.Zero, IntPtr.Zero, null);
@ -95,12 +98,23 @@ namespace Avalonia.Win32
{
Imm32InputMethod.Current.ClearLanguageAndWindow();
}
// Cleanup render targets
(_gl as IDisposable)?.Dispose();
if (_dropTarget != null)
{
OleContext.Current?.UnregisterDragDrop(Handle);
_dropTarget.Dispose();
_dropTarget = null;
}
_framebuffer.Dispose();
//Window doesn't exist anymore
_hwnd = IntPtr.Zero;
//Remove root reference to this class, so unmanaged delegate can be collected
s_instances.Remove(this);
Closed?.Invoke();
_mouseDevice.Dispose();
_touchDevice.Dispose();

18
src/Windows/Avalonia.Win32/WindowImpl.cs

@ -150,7 +150,12 @@ namespace Avalonia.Win32
CreateWindow();
_framebuffer = new FramebufferManager(_hwnd);
UpdateInputMethod(GetKeyboardLayout(0));
if (this is not PopupImpl)
{
UpdateInputMethod(GetKeyboardLayout(0));
}
if (glPlatform != null)
{
if (_isUsingComposition)
@ -619,15 +624,6 @@ namespace Avalonia.Win32
public void Dispose()
{
(_gl as IDisposable)?.Dispose();
if (_dropTarget != null)
{
OleContext.Current?.UnregisterDragDrop(Handle);
_dropTarget.Dispose();
_dropTarget = null;
}
if (_hwnd != IntPtr.Zero)
{
// Detect if we are being closed programmatically - this would mean that WM_CLOSE was not called
@ -640,8 +636,6 @@ namespace Avalonia.Win32
DestroyWindow(_hwnd);
_hwnd = IntPtr.Zero;
}
_framebuffer.Dispose();
}
public void Invalidate(Rect rect)

2
tests/Avalonia.Base.UnitTests/Media/FontManagerTests.cs

@ -43,7 +43,7 @@ namespace Avalonia.Base.UnitTests.Media
{
AvaloniaLocator.CurrentMutable.Bind<FontManagerOptions>().ToConstant(options);
Assert.Equal("MyFont", FontManager.Current.DefaultFontFamilyName);
Assert.Equal("MyFont", FontManager.Current.DefaultFontFamily.Name);
}
}

918
tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs

File diff suppressed because it is too large

475
tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs

@ -1,17 +1,22 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests
{
public class ListBoxTests_Multiple
{
private MouseTestHelper _helper = new MouseTestHelper();
[Fact]
public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection()
{
@ -82,6 +87,468 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
}
[Fact]
public void Shift_Selecting_From_No_Selection_Selects_From_Start()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
}
}
[Fact]
public void Ctrl_Selecting_Raises_SelectionChanged_Events()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
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(target.Presenter.Panel.Children[1]);
VerifyAdded("Bar");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
VerifyAdded("Baz");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
VerifyAdded("Qux");
receivedArgs = null;
_helper.Click(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()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[1]);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
_helper.Click(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(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
Assert.Equal(2, target.SelectedIndex);
Assert.Equal("Baz", target.SelectedItem);
Assert.Equal(new[] { "Baz", "Qux" }, target.SelectedItems);
}
}
[Fact]
public void Ctrl_Selecting_Non_SelectedItem_With_Multiple_Selection_Active_Leaves_SelectedItem_The_Same()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[1]);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("Bar", target.SelectedItem);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("Bar", target.SelectedItem);
}
}
[Fact]
public void Should_Ctrl_Select_Correct_Item_When_Duplicate_Items_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[3]);
_helper.Click(target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
Assert.Equal(new[] { 3, 4 }, SelectedContainers(target));
}
}
[Fact]
public void Should_Shift_Select_Correct_Item_When_Duplicates_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[3]);
_helper.Click(target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 3, 4, 5 }, SelectedContainers(target));
}
}
[Fact]
public void Can_Shift_Select_All_Items_When_Duplicates_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target));
}
}
[Fact]
public void Shift_Selecting_Raises_SelectionChanged_Events()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
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(target.Presenter.Panel.Children[1]);
VerifyAdded("Bar");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Shift);
VerifyAdded("Baz", "Qux");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
VerifyRemoved("Qux");
}
}
[Fact]
public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
Assert.Equal(new[] { "Foo" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems);
}
}
[Fact]
public void Left_Click_On_SelectedItem_Should_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectAll();
Assert.Equal(3, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[0]);
Assert.Equal(1, target.SelectedItems.Count);
Assert.Equal(new[] { "Foo", }, target.SelectedItems);
Assert.Equal(new[] { 0 }, SelectedContainers(target));
}
}
[Fact]
public void Right_Click_On_SelectedItem_Should_Not_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectAll();
Assert.Equal(3, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[0], MouseButton.Right);
Assert.Equal(3, target.SelectedItems.Count);
}
}
[Fact]
public void Right_Click_On_UnselectedItem_Should_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Shift);
Assert.Equal(2, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right);
Assert.Equal(1, target.SelectedItems.Count);
}
}
[Fact]
public void Shift_Right_Click_Should_Not_Select_Multiple()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Shift);
Assert.Equal(1, target.SelectedItems.Count);
}
}
[Fact]
public void Ctrl_Right_Click_Should_Not_Select_Multiple()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedItems.Count);
}
}
private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope)
{
return new ScrollViewer
@ -119,5 +586,13 @@ namespace Avalonia.Controls.UnitTests
// Now the ItemsPresenter should be reigstered, so apply its template.
((Control)target.Presenter).ApplyTemplate();
}
private static IEnumerable<int> SelectedContainers(SelectingItemsControl target)
{
return target.Presenter.Panel.Children
.Select(x => x.Classes.Contains(":selected") ? target.IndexFromContainer(x) : -1)
.Where(x => x != -1);
}
}
}

7
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@ -2170,7 +2170,12 @@ namespace Avalonia.Controls.UnitTests.Primitives
private class Item : Control, ISelectable
{
public string Value { get; set; }
public bool IsSelected { get; set; }
public bool IsSelected
{
get => SelectingItemsControl.GetIsSelected(this);
set => SelectingItemsControl.SetIsSelected(this, value);
}
}
private class MasterViewModel : NotifyingBase

1
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@ -116,7 +116,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal(0, target.SelectedIndex);
Assert.Equal("bar", target.SelectedItem);
Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[0].Classes);
}
private static FuncControlTemplate Template()

1579
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

File diff suppressed because it is too large

249
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Data.Core;
@ -219,7 +220,7 @@ namespace Avalonia.Controls.UnitTests
Assert.True(fromContainer.IsSelected);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -238,7 +239,7 @@ namespace Avalonia.Controls.UnitTests
Assert.True(fromContainer.IsSelected);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -255,7 +256,7 @@ namespace Avalonia.Controls.UnitTests
ClickContainer(fromContainer, KeyModifiers.None);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
ClickContainer(fromContainer, KeyModifiers.None);
Assert.True(fromContainer.IsSelected);
@ -975,7 +976,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1005,7 +1006,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1035,7 +1036,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1047,7 +1048,7 @@ namespace Avalonia.Controls.UnitTests
target.SelectAll();
AssertChildrenSelected(target, data[0]);
AssertAllChildContainersSelected(target, data[0]);
Assert.Equal(5, target.SelectedItems.Count);
_mouse.Click(target.Presenter!.Panel!.Children[0], MouseButton.Right);
@ -1259,6 +1260,174 @@ namespace Avalonia.Controls.UnitTests
}
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_ItemContainerTheme()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true);
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_Style()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var style = new Style(x => x.OfType<TreeViewItem>())
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, multiSelect: true, styles: new[] { style });
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
selected[0].IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true);
selected[1].IsSelected = true;
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(
data: data,
expandAll: false,
itemContainerTheme: itemTheme,
multiSelect: true);
var rootContainer = Assert.IsType<TreeViewItem>(target.ContainerFromIndex(0));
// Root TreeViewItem isn't expanded so selection for child won't have been picked
// up by IsSelected binding yet.
AssertContainerSelection(target, new[] { selected[0] });
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(new[] { selected[0] }, target.SelectedItems);
rootContainer.IsExpanded = true;
Layout(target);
// Root is expanded so now all expected items will be selected.
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand_Single_Select()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(
data: data,
expandAll: false,
itemContainerTheme: itemTheme);
var rootContainer = Assert.IsType<TreeViewItem>(target.ContainerFromIndex(0));
// Root TreeViewItem isn't expanded so selection for child won't have been picked
// up by IsSelected binding yet.
AssertContainerSelection(target, new[] { selected[0] });
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(new[] { selected[0] }, target.SelectedItems);
rootContainer.IsExpanded = true;
Layout(target);
// Root is expanded and newly revealed selected node will replace current selection
// given that we're in SelectionMode == Single.
selected = new[] { selected[1] };
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
private static TreeView CreateTarget(Optional<IList<Node>?> data = default,
bool expandAll = true,
ControlTheme? itemContainerTheme = null,
@ -1465,17 +1634,61 @@ namespace Avalonia.Controls.UnitTests
_mouse.Click(container, modifiers: modifiers);
}
private void AssertChildrenSelected(TreeView treeView, Node rootNode)
private void AssertContainerSelection(TreeView treeView, params Node[] expected)
{
static void Evaluate(Control container, HashSet<Node> remaining)
{
var treeViewItem = Assert.IsType<TreeViewItem>(container);
var node = (Node)container.DataContext!;
Assert.Equal(remaining.Contains(node), treeViewItem.IsSelected);
remaining.Remove(node);
foreach (var child in treeViewItem.GetRealizedContainers())
{
Evaluate(child, remaining);
}
}
var remaining = expected.ToHashSet();
foreach (var container in treeView.GetRealizedContainers())
Evaluate(container, remaining);
Assert.Empty(remaining);
}
private void AssertAllChildContainersSelected(TreeView treeView, Node node)
{
Assert.NotNull(rootNode.Children);
Assert.NotNull(node.Children);
foreach (var child in rootNode.Children)
foreach (var child in node.Children)
{
var container = Assert.IsType<TreeViewItem>(treeView.TreeContainerFromItem(child));
Assert.True(container.IsSelected);
}
}
private void AssertDataSelection(IEnumerable<Node> data, params Node[] expected)
{
static void Evaluate(Node rootNode, HashSet<Node> remaining)
{
Assert.Equal(remaining.Contains(rootNode), rootNode.IsSelected);
remaining.Remove(rootNode);
if (rootNode.Children is null)
return;
foreach (var child in rootNode.Children)
{
Evaluate(child, remaining);
}
}
var remaining = expected.ToHashSet();
foreach (var node in data)
Evaluate(node, remaining);
Assert.Empty(remaining);
}
private IDisposable Start()
{
return UnitTestApplication.Start(
@ -1492,6 +1705,7 @@ namespace Avalonia.Controls.UnitTests
private class Node : NotifyingBase
{
private IAvaloniaList<Node> _children = new AvaloniaList<Node>();
private bool _isSelected;
public string? Value { get; set; }
@ -1504,6 +1718,21 @@ namespace Avalonia.Controls.UnitTests
RaisePropertyChanged(nameof(Children));
}
}
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
RaisePropertyChanged();
}
}
}
public override string ToString() => Value ?? string.Empty;
}
private class TestTreeDataTemplate : ITreeDataTemplate

213
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@ -15,6 +15,8 @@ using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Xunit;
#nullable enable
namespace Avalonia.Controls.UnitTests
{
public class VirtualizingStackPanelTests
@ -35,7 +37,7 @@ namespace Avalonia.Controls.UnitTests
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10});
var (target, scroll, itemsControl) = CreateTarget(items: items, useItemTemplate: false);
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null);
Assert.Equal(1000, scroll.Extent.Height);
@ -81,7 +83,7 @@ namespace Avalonia.Controls.UnitTests
}
[Fact]
public void Scrolls_To_Index()
public void Scrolls_Down_To_Index()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
@ -91,6 +93,35 @@ namespace Avalonia.Controls.UnitTests
AssertRealizedItems(target, itemsControl, 11, 10);
}
[Fact]
public void Scrolls_Up_To_Index()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.ScrollToEnd();
Layout(target);
Assert.Equal(90, target.FirstRealizedIndex);
target.ScrollIntoView(20);
AssertRealizedItems(target, itemsControl, 20, 10);
}
[Fact]
public void Scrolling_Up_To_Index_Does_Not_Create_A_Page_Of_Unrealized_Elements()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.ScrollToEnd();
Layout(target);
target.ScrollIntoView(20);
Assert.Equal(11, target.Children.Count);
}
[Fact]
public void Creates_Elements_On_Item_Insert_1()
{
@ -250,7 +281,7 @@ namespace Avalonia.Controls.UnitTests
{
using var app = App();
var items = new ObservableCollection<Button>(Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }));
var (target, scroll, itemsControl) = CreateTarget(items: items, useItemTemplate: false);
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null);
Assert.Equal(1000, scroll.Extent.Height);
@ -312,6 +343,24 @@ namespace Avalonia.Controls.UnitTests
Assert.Same(focused, target.GetRealizedElements().First());
}
[Fact]
public void Focusing_Another_Element_Recycles_Original_Focus_Element()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var originalFocused = target.GetRealizedElements().First()!;
originalFocused.Focus();
scroll.Offset = new Vector(0, 500);
Layout(target);
var newFocused = target.GetRealizedElements().First()!;
newFocused.Focus();
Assert.False(originalFocused.IsVisible);
}
[Fact]
public void Removing_Range_When_Scrolled_To_End_Updates_Viewport()
{
@ -470,6 +519,122 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(10, raised);
}
[Fact]
public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_At_End()
{
using var app = App();
var items = Enumerable.Range(0, 1000).Select(x => new ItemWithHeight(x)).ToList();
items[20].Height = 200;
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
new Canvas
{
Width = 100,
[!Canvas.HeightProperty] = new Binding("Height"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
var index = target.FirstRealizedIndex;
// Scroll down to the larger element.
while (target.LastRealizedIndex < items.Count - 1)
{
scroll.LineDown();
Layout(target);
Assert.True(
target.FirstRealizedIndex >= index,
$"{target.FirstRealizedIndex} is not greater or equal to {index}");
if (scroll.Offset.Y + scroll.Viewport.Height == scroll.Extent.Height)
Assert.Equal(items.Count - 1, target.LastRealizedIndex);
index = target.FirstRealizedIndex;
}
}
[Fact]
public void Scrolling_Up_To_Larger_Element_Does_Not_Cause_Jump()
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x)).ToList();
items[20].Height = 200;
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
new Canvas
{
Width = 100,
[!Canvas.HeightProperty] = new Binding("Height"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
// Scroll past the larger element.
scroll.Offset = new Vector(0, 600);
Layout(target);
// Precondition checks
Assert.True(target.FirstRealizedIndex > 20);
var index = target.FirstRealizedIndex;
// Scroll up to the top.
while (scroll.Offset.Y > 0)
{
scroll.LineUp();
Layout(target);
Assert.True(target.FirstRealizedIndex <= index, $"{target.FirstRealizedIndex} is not less than {index}");
index = target.FirstRealizedIndex;
}
}
[Fact]
public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump()
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x, 30)).ToList();
items[20].Height = 25;
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
new Canvas
{
Width = 100,
[!Canvas.HeightProperty] = new Binding("Height"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
// Scroll past the larger element.
scroll.Offset = new Vector(0, 25 * items[0].Height);
Layout(target);
// Precondition checks
Assert.True(target.FirstRealizedIndex > 20);
var index = target.FirstRealizedIndex;
// Scroll up to the top.
while (scroll.Offset.Y > 0)
{
scroll.Offset = scroll.Offset - new Vector(0, 5);
Layout(target);
Assert.True(
target.FirstRealizedIndex <= index,
$"{target.FirstRealizedIndex} is not less than {index}");
Assert.True(
index - target.FirstRealizedIndex <= 1,
$"FirstIndex changed from {index} to {target.FirstRealizedIndex}");
index = target.FirstRealizedIndex;
}
}
private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
{
return target.GetRealizedElements()
@ -483,10 +648,10 @@ namespace Avalonia.Controls.UnitTests
int firstIndex,
int count)
{
Assert.All(target.GetRealizedContainers(), x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers(), x => Assert.Same(itemsControl, x.Parent));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(itemsControl, x.Parent));
var childIndexes = target.GetRealizedContainers()?
var childIndexes = target.GetRealizedContainers()!
.Select(x => itemsControl.IndexFromContainer(x))
.Where(x => x >= 0)
.OrderBy(x => x)
@ -500,11 +665,11 @@ namespace Avalonia.Controls.UnitTests
int firstIndex,
int count)
{
Assert.All(target.GetRealizedContainers(), x => Assert.IsType<TContainer>(x));
Assert.All(target.GetRealizedContainers(), x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers(), x => Assert.Same(itemsControl, x.Parent));
Assert.All(target.GetRealizedContainers()!, x => Assert.IsType<TContainer>(x));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(itemsControl, x.Parent));
var childIndexes = target.GetRealizedContainers()?
var childIndexes = target.GetRealizedContainers()!
.Select(x => itemsControl.IndexFromContainer(x))
.Where(x => x >= 0)
.OrderBy(x => x)
@ -514,7 +679,7 @@ namespace Avalonia.Controls.UnitTests
private static (VirtualizingStackPanel, ScrollViewer, ItemsControl) CreateTarget(
IEnumerable<object>? items = null,
bool useItemTemplate = true,
Optional<IDataTemplate?> itemTemplate = default,
IEnumerable<Style>? styles = null)
{
var target = new VirtualizingStackPanel();
@ -528,6 +693,7 @@ namespace Avalonia.Controls.UnitTests
var scroll = new ScrollViewer
{
Name = "PART_ScrollViewer",
Content = presenter,
Template = ScrollViewerTemplate(),
};
@ -535,13 +701,11 @@ namespace Avalonia.Controls.UnitTests
var itemsControl = new ItemsControl
{
ItemsSource = items,
Template = new FuncControlTemplate<ItemsControl>((_, _) => scroll),
ItemsPanel = new FuncTemplate<Panel>(() => target),
Template = new FuncControlTemplate<ItemsControl>((_, ns) => scroll.RegisterInNameScope(ns)),
ItemsPanel = new FuncTemplate<Panel?>(() => target),
ItemTemplate = itemTemplate.GetValueOrDefault(DefaultItemTemplate()),
};
if (useItemTemplate)
itemsControl.ItemTemplate = new FuncDataTemplate<object>((x, _) => new Canvas { Width = 100, Height = 10 });
var root = new TestRoot(true, itemsControl);
root.ClientSize = new(100, 100);
@ -553,6 +717,11 @@ namespace Avalonia.Controls.UnitTests
return (target, scroll, itemsControl);
}
private static IDataTemplate DefaultItemTemplate()
{
return new FuncDataTemplate<object>((x, _) => new Canvas { Width = 100, Height = 10 });
}
private static void Layout(Control target)
{
var root = (ILayoutRoot?)target.GetVisualRoot();
@ -570,6 +739,18 @@ namespace Avalonia.Controls.UnitTests
private static IDisposable App() => UnitTestApplication.Start(TestServices.RealFocus);
private class ItemWithHeight
{
public ItemWithHeight(int index, double height = 10)
{
Caption = $"Item {index}";
Height = height;
}
public string Caption { get; set; }
public double Height { get; set; }
}
private class ResettingCollection : List<string>, INotifyCollectionChanged
{
public ResettingCollection(IEnumerable<string> items)

2
tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs

@ -50,7 +50,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
var glyphTypeface = new Typeface(new FontFamily("Unknown")).GlyphTypeface;
var defaultName = FontManager.Current.DefaultFontFamilyName;
var defaultName = FontManager.Current.DefaultFontFamily.Name;
Assert.Equal(defaultName, glyphTypeface.FamilyName);
}

54
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ResourceDictionaryTests.cs

@ -307,6 +307,60 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
Assert.True(buttonResources.ContainsDeferredKey("Red"));
}
}
[Fact]
public void Should_Be_Possible_To_Redefine_Referenced_Resource_ControlTheme()
{
using (StyledWindow())
{
var xaml = @"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
<Window.Resources>
<ControlTheme x:Key='{x:Type Button}' TargetType='Button' />
</Window.Resources>
<UserControl>
<UserControl.Resources>
<ControlTheme x:Key='{x:Type Button}' TargetType='Button' BasedOn='{StaticResource {x:Type Button}}' />
</UserControl.Resources>
</UserControl>
</Window>";
var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
var windowResources = (ResourceDictionary)window.Resources;
var innerResources = (ResourceDictionary)((UserControl)window.Content!).Resources;
var winButtonTheme = Assert.IsType<ControlTheme>(windowResources[typeof(Button)]);
var innerButtonTheme = Assert.IsType<ControlTheme>(innerResources[typeof(Button)]);
Assert.Equal(winButtonTheme, innerButtonTheme.BasedOn);
}
}
[Fact]
public void Should_Be_Possible_To_Redefine_Referenced_Resource()
{
using (StyledWindow())
{
var xaml = @"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
<Window.Resources>
<Color x:Key='SystemAccentColor'>#aaa</Color>
</Window.Resources>
<UserControl>
<UserControl.Resources>
<StaticResource x:Key='SystemAccentColor' ResourceKey='SystemAccentColor' />
</UserControl.Resources>
</UserControl>
</Window>";
var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
var windowResources = (ResourceDictionary)window.Resources;
var innerResources = (ResourceDictionary)((UserControl)window.Content!).Resources;
var winButtonTheme = Assert.IsType<Color>(windowResources["SystemAccentColor"]);
var innerButtonTheme = Assert.IsType<Color>(innerResources["SystemAccentColor"]);
Assert.Equal(winButtonTheme, innerButtonTheme);
}
}
[Fact]
public void Dynamically_Changing_Referenced_Resources_Works_With_DynamicResource()

51
tests/Avalonia.RenderTests/Media/BoxShadowTests.cs

@ -0,0 +1,51 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media;
using Xunit;
#pragma warning disable CS0649
#if AVALONIA_SKIA
namespace Avalonia.Skia.RenderTests;
public class BoxShadowTests : TestBase
{
public BoxShadowTests() : base(@"Media\BoxShadow")
{
}
[Fact]
public async Task BoxShadowShouldBeRenderedEvenWithNullBrushAndPen()
{
var target = new Border
{
Width = 200,
Height = 200,
Background = null,
Child = new Border()
{
Background = null,
Margin = new Thickness(40),
BoxShadow = new BoxShadows(new BoxShadow
{
Blur = 0,
Color = Colors.Blue,
OffsetX = 10,
OffsetY = 15,
Spread = 0
}),
Child = new Border
{
Background = Brushes.Red
}
}
};
await RenderToFile(target);
CompareImages();
}
}
#endif

159
tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs

@ -12,24 +12,17 @@ namespace Avalonia.Skia.UnitTests.Media
{
public class CustomFontManagerImpl : IFontManagerImpl
{
private readonly Typeface[] _customTypefaces;
private readonly string _defaultFamilyName;
private readonly Typeface _defaultTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono");
private readonly Typeface _arabicTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans Arabic");
private readonly Typeface _hebrewTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans Hebrew");
private readonly Typeface _italicTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans", FontStyle.Italic);
private readonly Typeface _emojiTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Twitter Color Emoji");
private readonly IFontCollection _customFonts;
private bool _isInitialized;
public CustomFontManagerImpl()
{
_customTypefaces = new[] { _emojiTypeface, _italicTypeface, _arabicTypeface, _hebrewTypeface, _defaultTypeface };
_defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName;
_defaultFamilyName = "Noto Mono";
var source = new Uri("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests");
_customFonts = new EmbeddedFontCollection(source, source);
}
public string GetDefaultFontFamilyName()
@ -39,28 +32,32 @@ namespace Avalonia.Skia.UnitTests.Media
public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false)
{
return _customTypefaces.Select(x => x.FontFamily.Name).ToArray();
if (!_isInitialized)
{
_customFonts.Initialize(this);
_isInitialized = true;
}
return _customFonts.Select(x=> x.Name).ToArray();
}
private readonly string[] _bcp47 = { CultureInfo.CurrentCulture.ThreeLetterISOLanguageName, CultureInfo.CurrentCulture.TwoLetterISOLanguageName };
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
FontFamily fontFamily,
CultureInfo culture, out Typeface typeface)
{
foreach (var customTypeface in _customTypefaces)
if (!_isInitialized)
{
if (customTypeface.GlyphTypeface.GetGlyph((uint)codepoint) == 0)
{
continue;
}
typeface = new Typeface(customTypeface.FontFamily, fontStyle, fontWeight);
_customFonts.Initialize(this);
}
if(_customFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, null, culture, out typeface))
{
return true;
}
var fallback = SKFontManager.Default.MatchCharacter(fontFamily?.Name, (SKFontStyleWeight)fontWeight,
var fallback = SKFontManager.Default.MatchCharacter(null, (SKFontStyleWeight)fontWeight,
(SKFontStyleWidth)fontStretch, (SKFontStyleSlant)fontStyle, _bcp47, codepoint);
typeface = new Typeface(fallback?.FamilyName ?? _defaultFamilyName, fontStyle, fontWeight);
@ -68,123 +65,21 @@ namespace Avalonia.Skia.UnitTests.Media
return true;
}
public IGlyphTypeface CreateGlyphTypeface(Typeface typeface)
{
SKTypeface skTypeface;
Uri source = null;
switch (typeface.FontFamily.Name)
{
case "Twitter Color Emoji":
{
source = _emojiTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans":
{
source = _italicTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans Arabic":
{
source = _arabicTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans Hebrew":
{
source = _hebrewTypeface.FontFamily.Key.Source;
break;
}
case FontFamily.DefaultFontFamilyName:
case "Noto Mono":
{
source = _defaultTypeface.FontFamily.Key.Source;
break;
}
default:
{
break;
}
}
if (source is null)
{
skTypeface = SKTypeface.FromFamilyName(typeface.FontFamily.Name,
(SKFontStyleWeight)typeface.Weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)typeface.Style);
}
else
{
var assetLoader = AvaloniaLocator.Current.GetRequiredService<IAssetLoader>();
var assetUri = FontFamilyLoader.LoadFontAssets(source).First();
var stream = assetLoader.Open(assetUri);
skTypeface = SKTypeface.FromStream(stream);
}
return new GlyphTypefaceImpl(skTypeface, FontSimulations.None);
}
public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface glyphTypeface)
{
SKTypeface skTypeface;
Uri source = null;
switch (familyName)
if (!_isInitialized)
{
case "Twitter Color Emoji":
{
source = _emojiTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans":
{
source = _italicTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans Arabic":
{
source = _arabicTypeface.FontFamily.Key.Source;
break;
}
case "Noto Sans Hebrew":
{
source = _hebrewTypeface.FontFamily.Key.Source;
break;
}
case FontFamily.DefaultFontFamilyName:
case "Noto Mono":
{
source = _defaultTypeface.FontFamily.Key.Source;
break;
}
default:
{
break;
}
_customFonts.Initialize(this);
}
if (source is null)
if (_customFonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface))
{
skTypeface = SKTypeface.FromFamilyName(familyName,
(SKFontStyleWeight)weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)style);
return true;
}
else
{
var assetLoader = AvaloniaLocator.Current.GetRequiredService<IAssetLoader>();
var assetUri = FontFamilyLoader.LoadFontAssets(source).First();
var stream = assetLoader.Open(assetUri);
skTypeface = SKTypeface.FromStream(stream);
}
var skTypeface = SKTypeface.FromFamilyName(familyName,
(SKFontStyleWeight)weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)style);
glyphTypeface = new GlyphTypefaceImpl(skTypeface, FontSimulations.None);

24
tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs

@ -17,7 +17,7 @@ namespace Avalonia.Skia.UnitTests.Media
{
var fontManager = FontManager.Current;
var glyphTypeface = new Typeface(new FontFamily("A, B, " + fontManager.DefaultFontFamilyName)).GlyphTypeface;
var glyphTypeface = new Typeface(new FontFamily("A, B, " + FontFamily.DefaultFontFamilyName)).GlyphTypeface;
Assert.Equal(SKTypeface.Default.FamilyName, glyphTypeface.FamilyName);
}
@ -41,7 +41,7 @@ namespace Avalonia.Skia.UnitTests.Media
{
var glyphTypeface = new Typeface(new FontFamily("Unknown")).GlyphTypeface;
Assert.Equal(FontManager.Current.DefaultFontFamilyName, glyphTypeface.FamilyName);
Assert.Equal(FontManager.Current.DefaultFontFamily.Name, glyphTypeface.FamilyName);
}
}
@ -87,6 +87,24 @@ namespace Avalonia.Skia.UnitTests.Media
}
}
[Fact]
public void Should_Only_Try_To_Create_GlyphTypeface_Once()
{
var fontManagerImpl = new MockFontManagerImpl();
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManagerImpl)))
{
Assert.True(FontManager.Current.TryGetGlyphTypeface(Typeface.Default, out _));
for (int i = 0;i < 10; i++)
{
FontManager.Current.TryGetGlyphTypeface(new Typeface("Unknown"), out _);
}
Assert.Equal(fontManagerImpl.TryCreateGlyphTypefaceCount, 2);
}
}
[Fact]
public void Should_Load_Embedded_DefaultFontFamily()
{
@ -96,7 +114,7 @@ namespace Avalonia.Skia.UnitTests.Media
{
AvaloniaLocator.CurrentMutable.BindToSelf(new FontManagerOptions { DefaultFamilyName = s_fontUri });
var result = FontManager.Current.TryGetGlyphTypeface(new Typeface(FontFamily.DefaultFontFamilyName), out var glyphTypeface);
var result = FontManager.Current.TryGetGlyphTypeface(Typeface.Default, out var glyphTypeface);
Assert.True(result);

4
tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs

@ -35,8 +35,8 @@ namespace Avalonia.UnitTests
return _customTypefaces.Select(x => x.FontFamily!.Name).ToArray();
}
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
FontFamily fontFamily, CultureInfo culture, out Typeface fontKey)
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, CultureInfo culture, out Typeface fontKey)
{
foreach (var customTypeface in _customTypefaces)
{

18
tests/Avalonia.UnitTests/MockFontManagerImpl.cs

@ -15,6 +15,8 @@ namespace Avalonia.UnitTests
_defaultFamilyName = defaultFamilyName;
}
public int TryCreateGlyphTypefaceCount { get; private set; }
public string GetDefaultFontFamilyName()
{
return _defaultFamilyName;
@ -26,7 +28,7 @@ namespace Avalonia.UnitTests
}
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, FontFamily fontFamily,
FontStretch fontStretch,
CultureInfo culture, out Typeface fontKey)
{
fontKey = new Typeface(_defaultFamilyName);
@ -34,14 +36,24 @@ namespace Avalonia.UnitTests
return false;
}
public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface glyphTypeface)
public virtual bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface glyphTypeface)
{
glyphTypeface = null;
TryCreateGlyphTypefaceCount++;
if (familyName == "Unknown")
{
return false;
}
glyphTypeface = new MockGlyphTypeface();
return true;
}
public bool TryCreateGlyphTypeface(Stream stream, out IGlyphTypeface glyphTypeface)
public virtual bool TryCreateGlyphTypeface(Stream stream, out IGlyphTypeface glyphTypeface)
{
glyphTypeface = new MockGlyphTypeface();

BIN
tests/TestFiles/Skia/Media/BoxShadow/BoxShadowShouldBeRenderedEvenWithNullBrushAndPen.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Loading…
Cancel
Save