From 891fd8dec4208bf856386a47b1ad5cf01c4cf089 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 28 Sep 2021 08:44:59 +0200 Subject: [PATCH 01/96] Try to load a custom font with all possible weights and styles --- .../Avalonia.Skia/SKTypefaceCollection.cs | 7 +++--- .../Media/SKTypefaceCollectionCacheTests.cs | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs index 7c4ff4edc0..21b2959089 100644 --- a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs +++ b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs @@ -27,15 +27,16 @@ namespace Avalonia.Skia { return typeface; } + + var initialWeight = (int)key.Weight; var weight = (int)key.Weight; - weight -= weight % 100; // make sure we start at a full weight + weight -= weight % 50; // make sure we start at a full weight for (var i = 0; i < 2; i++) { - // only try 2 font weights in each direction - for (var j = 0; j < 200; j += 100) + for (var j = 0; j < initialWeight; j += 50) { if (weight - j >= 100) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs b/tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs index 68813f28ab..ddf4a36dcd 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs @@ -6,18 +6,24 @@ namespace Avalonia.Skia.UnitTests.Media { public class SKTypefaceCollectionCacheTests { - [Fact] - public void Should_Get_Near_Matching_Typeface() + private const string s_notoMono = + "resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"; + + [InlineData(s_notoMono, FontWeight.SemiLight, FontStyle.Normal)] + [InlineData(s_notoMono, FontWeight.Bold, FontStyle.Italic)] + [InlineData(s_notoMono, FontWeight.Heavy, FontStyle.Oblique)] + [Theory] + public void Should_Get_Near_Matching_Typeface(string familyName, FontWeight fontWeight, FontStyle fontStyle) { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - var notoMono = - new FontFamily("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); - - var notoMonoCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(notoMono); + var fontFamily = new FontFamily(familyName); + + var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(fontFamily); - Assert.Equal("Noto Mono", - notoMonoCollection.Get(new Typeface(notoMono, weight: FontWeight.Bold)).FamilyName); + var actual = typefaceCollection.Get(new Typeface(fontFamily, fontStyle, fontWeight))?.FamilyName; + + Assert.Equal("Noto Mono", actual); } } From 00765d53b2eeed4550785b850acf82d71da3479c Mon Sep 17 00:00:00 2001 From: Yoh Deadfall Date: Mon, 11 Oct 2021 20:46:58 +0300 Subject: [PATCH 02/96] Fixed handled event misses in dev tools --- .../Diagnostics/Models/EventChainLink.cs | 2 +- .../Diagnostics/ViewModels/EventTreeNode.cs | 26 +++++++++++++++++++ .../Diagnostics/ViewModels/FiredEvent.cs | 5 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs b/src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs index 4f493bdcc2..d986a11c45 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs @@ -29,7 +29,7 @@ namespace Avalonia.Diagnostics.Models } } - public bool Handled { get; } + public bool Handled { get; set; } public RoutingStrategies Route { get; } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs index 65fd81cc78..a79816390d 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs @@ -55,6 +55,8 @@ namespace Avalonia.Diagnostics.ViewModels // FIXME: This leaks event handlers. Event.AddClassHandler(typeof(object), HandleEvent, allRoutes, handledEventsToo: true); + Event.RouteFinished.Subscribe(HandleRouteFinished); + _isRegistered = true; } } @@ -92,6 +94,30 @@ namespace Avalonia.Diagnostics.ViewModels else handler(); } + + private void HandleRouteFinished(RoutedEventArgs e) + { + if (!_isRegistered || IsEnabled == false) + return; + if (e.Source is IVisual v && BelongsToDevTool(v)) + return; + + var s = e.Source; + var handled = e.Handled; + var route = e.Route; + + void handler() + { + if (_currentEvent != null && handled) + { + var linkIndex = _currentEvent.EventChain.Count - 1; + var link = _currentEvent.EventChain[linkIndex]; + + link.Handled = true; + _currentEvent.HandledBy = link; + } + } + } private static bool BelongsToDevTool(IVisual v) { diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs index 32df2f8745..8069300922 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs @@ -63,8 +63,8 @@ namespace Avalonia.Diagnostics.ViewModels { if (EventChain.Count > 0) { - var prevLink = EventChain[EventChain.Count-1]; - + var prevLink = EventChain[EventChain.Count - 1]; + if (prevLink.Route != link.Route) { link.BeginsNewRoute = true; @@ -72,6 +72,7 @@ namespace Avalonia.Diagnostics.ViewModels } EventChain.Add(link); + if (HandledBy == null && link.Handled) HandledBy = link; } From fa6a66c765410fed286b712ec41b82df4efe357e Mon Sep 17 00:00:00 2001 From: Takoooooo Date: Mon, 18 Oct 2021 17:47:57 +0300 Subject: [PATCH 03/96] fix --- src/Avalonia.Controls/RepeatButton.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Avalonia.Controls/RepeatButton.cs b/src/Avalonia.Controls/RepeatButton.cs index ba770634d9..aff4893a55 100644 --- a/src/Avalonia.Controls/RepeatButton.cs +++ b/src/Avalonia.Controls/RepeatButton.cs @@ -78,6 +78,10 @@ namespace Avalonia.Controls { StartTimer(); } + else if (e.Key == Key.Tab) + { + StopTimer(); + } } protected override void OnKeyUp(KeyEventArgs e) From a4827cc71d5879942270f6327cf2e03b12c23f46 Mon Sep 17 00:00:00 2001 From: Takoooooo Date: Mon, 18 Oct 2021 18:05:13 +0300 Subject: [PATCH 04/96] Revert "fix" This reverts commit fa6a66c765410fed286b712ec41b82df4efe357e. --- src/Avalonia.Controls/RepeatButton.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Avalonia.Controls/RepeatButton.cs b/src/Avalonia.Controls/RepeatButton.cs index aff4893a55..ba770634d9 100644 --- a/src/Avalonia.Controls/RepeatButton.cs +++ b/src/Avalonia.Controls/RepeatButton.cs @@ -78,10 +78,6 @@ namespace Avalonia.Controls { StartTimer(); } - else if (e.Key == Key.Tab) - { - StopTimer(); - } } protected override void OnKeyUp(KeyEventArgs e) From 45c8867a785107c8217b2d3d4db7d1a5186a9c23 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 18 Oct 2021 23:11:49 +0300 Subject: [PATCH 05/96] Bump SDK to 5.0.302 --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index b2b2da7c4f..55a59d3ab5 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "3.1.401" + "version": "5.0.302" }, "msbuild-sdks": { "Microsoft.Build.Traversal": "1.0.43", From 4a2eb925f56497bfceffdb4c05de2102fbc5518d Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 18 Oct 2021 23:21:46 +0300 Subject: [PATCH 06/96] Download 3.1 runtime for *nix --- build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sh b/build.sh index bd162fab9b..9c957612cf 100755 --- a/build.sh +++ b/build.sh @@ -65,6 +65,8 @@ else else "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path fi + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.408 --runtime dotnet + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.408 --runtime aspnetcore fi export PATH=$DOTNET_DIRECTORY:$PATH From 2f0eb86e7121c57e64243cf4a0251ffc35d8ba79 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 18 Oct 2021 23:25:51 +0300 Subject: [PATCH 07/96] Use 3.1.20 --- build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 9c957612cf..7703e90a78 100755 --- a/build.sh +++ b/build.sh @@ -65,8 +65,8 @@ else else "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path fi - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.408 --runtime dotnet - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.408 --runtime aspnetcore + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.20 --runtime dotnet + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.20 --runtime aspnetcore fi export PATH=$DOTNET_DIRECTORY:$PATH From e078aa253daf625df49a5f4c494c9fb33ab3e50a Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 18 Oct 2021 23:51:24 +0300 Subject: [PATCH 08/96] Download SDK separately (unix) --- .gitignore | 1 + azure-pipelines.yml | 32 ++++++++++++----------------- build.sh | 50 ++------------------------------------------- get-sdk.sh | 22 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 67 deletions(-) create mode 100755 get-sdk.sh diff --git a/.gitignore b/.gitignore index abf7674560..1f91a11604 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,4 @@ obj-Skia/ coc-settings.json .ccls-cache .ccls +sdk diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 11ef36d43f..bc60f84107 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -4,19 +4,18 @@ jobs: vmImage: 'ubuntu-20.04' steps: - task: CmdLine@2 - displayName: 'Install Nuke' + displayName: 'Download SDK' inputs: script: | - dotnet tool install --global Nuke.GlobalTool --version 0.24.0 + ./get-sdk.sh - task: CmdLine@2 - displayName: 'Run Nuke' + displayName: 'Run Build' inputs: script: | - export PATH="$PATH:$HOME/.dotnet/tools" + export PATH="`pwd`/sdk:$PATH" dotnet --info printenv - nuke --target CiAzureLinux --configuration=Release - + ./build.sh --target CiAzureLinux --configuration=Release - task: PublishTestResults@2 inputs: testResultsFormat: 'VSTest' @@ -29,11 +28,11 @@ jobs: pool: vmImage: 'macOS-10.15' steps: - - task: UseDotNet@2 - displayName: 'Use .NET Core SDK 3.1.401' + - task: CmdLine@2 + displayName: 'Download SDK' inputs: - version: 3.1.401 - + script: | + ./get-sdk.sh - task: CmdLine@2 displayName: 'Install Mono 5.18' inputs: @@ -45,6 +44,7 @@ jobs: displayName: 'Generate avalonia-native' inputs: script: | + export PATH="`pwd`/sdk:$PATH" cd src/tools/MicroComGenerator; dotnet run -i ../../Avalonia.Native/avn.idl --cpp ../../../native/Avalonia.Native/inc/avalonia-native.h - task: Xcode@5 @@ -58,13 +58,7 @@ jobs: args: '-derivedDataPath ./' - task: CmdLine@2 - displayName: 'Install Nuke' - inputs: - script: | - dotnet tool install --global Nuke.GlobalTool --version 0.24.0 - - - task: CmdLine@2 - displayName: 'Run Nuke' + displayName: 'Run Build' inputs: script: | export COREHOST_TRACE=0 @@ -72,10 +66,10 @@ jobs: export DOTNET_CLI_TELEMETRY_OPTOUT=1 which dotnet dotnet --info - export PATH="$PATH:$HOME/.dotnet/tools" + export PATH="`pwd`/sdk:$PATH" dotnet --info printenv - nuke --target CiAzureOSX --configuration Release --skip-previewer + ./build.sh --target CiAzureOSX --configuration Release --skip-previewer - task: PublishTestResults@2 inputs: diff --git a/build.sh b/build.sh index 7703e90a78..9532b4fbe0 100755 --- a/build.sh +++ b/build.sh @@ -20,57 +20,11 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) ########################################################################### BUILD_PROJECT_FILE="$SCRIPT_DIR/nukebuild/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.tmp" - -DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" -DOTNET_INSTALL_URL="https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain/dotnet-install.sh" -DOTNET_CHANNEL="Current" export DOTNET_CLI_TELEMETRY_OPTOUT=1 export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 export NUGET_XMLDOC_MODE="skip" -########################################################################### -# EXECUTION -########################################################################### - -function FirstJsonValue { - perl -nle 'print $1 if m{"'$1'": "([^"\-]+)",?}' <<< ${@:2} -} - -# If global.json exists, load expected version -if [ -f "$DOTNET_GLOBAL_FILE" ]; then - DOTNET_VERSION=$(FirstJsonValue "version" $(cat "$DOTNET_GLOBAL_FILE")) - if [ "$DOTNET_VERSION" == "" ]; then - unset DOTNET_VERSION - fi -fi - -# If dotnet is installed locally, and expected version is not set or installation matches the expected version -if [[ -x "$(command -v dotnet)" && (-z ${DOTNET_VERSION+x} || $(dotnet --version) == "$DOTNET_VERSION") || "$SKIP_DOTNET_DOWNLOAD" == "1" ]]; then - export DOTNET_EXE="$(command -v dotnet)" -else - DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" - export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" - - # Download install script - DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" - mkdir -p "$TEMP_DIRECTORY" - curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" - chmod +x "$DOTNET_INSTALL_FILE" - - # Install by channel or version - if [ -z ${DOTNET_VERSION+x} ]; then - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path - else - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path - fi - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.20 --runtime dotnet - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version 3.1.20 --runtime aspnetcore -fi - -export PATH=$DOTNET_DIRECTORY:$PATH - -echo "Microsoft (R) .NET Core SDK version $("$DOTNET_EXE" --version)" +dotnet --info -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" -- ${BUILD_ARGUMENTS[@]} +dotnet run --project "$BUILD_PROJECT_FILE" -- ${BUILD_ARGUMENTS[@]} diff --git a/get-sdk.sh b/get-sdk.sh new file mode 100755 index 0000000000..4427948864 --- /dev/null +++ b/get-sdk.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +SDK_DIR=$SCRIPT_DIR/sdk + +DOTNET_INSTALL_FILE="$SDK_DIR/dotnet-install.sh" +DOTNET_INSTALL_URL="https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain/dotnet-install.sh" + +DOTNET_VERSION=5.0.302 + +mkdir -p "$SDK_DIR" + +if [ ! -f "$DOTNET_INSTALL_FILE" ]; then + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" +fi + +"$DOTNET_INSTALL_FILE" --install-dir "$SDK_DIR" --version 5.0.302 +"$DOTNET_INSTALL_FILE" --install-dir "$SDK_DIR" --version 3.1.20 --runtime dotnet +"$DOTNET_INSTALL_FILE" --install-dir "$SDK_DIR" --version 3.1.20 --runtime aspnetcore + + From 6138a273cac544c1bda99746749ba1a83264334d Mon Sep 17 00:00:00 2001 From: Takoooooo Date: Tue, 19 Oct 2021 15:28:21 +0300 Subject: [PATCH 09/96] fix --- src/Avalonia.Controls/RepeatButton.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Avalonia.Controls/RepeatButton.cs b/src/Avalonia.Controls/RepeatButton.cs index ba770634d9..a21725cadf 100644 --- a/src/Avalonia.Controls/RepeatButton.cs +++ b/src/Avalonia.Controls/RepeatButton.cs @@ -70,6 +70,16 @@ namespace Avalonia.Controls _repeatTimer?.Stop(); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsPressedProperty && change.NewValue.GetValueOrDefault() == false) + { + StopTimer(); + } + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); From a246b8533f47c74d4a43657a2641d98c710fe61d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 21 Oct 2021 17:24:05 +0200 Subject: [PATCH 10/96] Hack to stop VS building everything every time. --- src/tools/MicroComGenerator/Program.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tools/MicroComGenerator/Program.cs b/src/tools/MicroComGenerator/Program.cs index 578ba1465d..2468b1b5a4 100644 --- a/src/tools/MicroComGenerator/Program.cs +++ b/src/tools/MicroComGenerator/Program.cs @@ -35,8 +35,16 @@ namespace MicroComGenerator if (opts.CppOutput != null) File.WriteAllText(opts.CppOutput, CppGen.GenerateCpp(ast)); + if (opts.CSharpOutput != null) + { File.WriteAllText(opts.CSharpOutput, new CSharpGen(ast).Generate()); + + // HACK: Can't work out how to get the VS project system's fast up-to-date checks + // to ignore the generated code, so as a workaround set the write time to that of + // the input. + File.SetLastWriteTime(opts.CSharpOutput, File.GetLastWriteTime(opts.Input)); + } return 0; } From 69fb1c056f6e4498752264f5fbdb9aa9e7c02114 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sun, 8 Aug 2021 03:07:17 -0400 Subject: [PATCH 11/96] FIrst version of nth-child --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 6 + src/Avalonia.Controls/ItemsControl.cs | 19 +- src/Avalonia.Controls/Panel.cs | 17 +- .../Presenters/ItemsPresenterBase.cs | 25 +- .../Utils/IEnumerableUtils.cs | 27 ++- .../Styling/NthChildSelector.cs | 134 +++++++++++ src/Avalonia.Styling/Styling/Selectors.cs | 10 + .../AvaloniaXamlIlSelectorTransformer.cs | 35 +++ .../Markup/Parsers/SelectorGrammar.cs | 105 ++++++++- .../Markup/Parsers/SelectorParser.cs | 6 + .../Parsers/SelectorGrammarTests.cs | 90 ++++++++ .../Xaml/StyleTests.cs | 59 +++++ .../SelectorTests_NthChild.cs | 217 ++++++++++++++++++ .../SelectorTests_NthLastChild.cs | 217 ++++++++++++++++++ 14 files changed, 955 insertions(+), 12 deletions(-) create mode 100644 src/Avalonia.Styling/Styling/NthChildSelector.cs create mode 100644 tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs create mode 100644 tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index f515db84d4..897134badb 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -2,9 +2,15 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.ListBoxPage"> + + + ListBox Hosts a collection of ListBoxItem. + Each 2nd item is highlighted Multiple diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 55645d4dbb..9c86aeb0c8 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -13,6 +13,7 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; +using Avalonia.Styling; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -21,7 +22,7 @@ namespace Avalonia.Controls /// Displays a collection of items. /// [PseudoClasses(":empty", ":singleitem")] - public class ItemsControl : TemplatedControl, IItemsPresenterHost, ICollectionChangedListener + public class ItemsControl : TemplatedControl, IItemsPresenterHost, ICollectionChangedListener, IChildIndexProvider { /// /// The default value for the property. @@ -506,5 +507,21 @@ namespace Avalonia.Controls return null; } + + (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + { + if (Presenter is IChildIndexProvider innerProvider) + { + return innerProvider.GetChildIndex(child); + } + + if (child is IControl control) + { + var index = ItemContainerGenerator.IndexFromContainer(control); + return (index, ItemCount); + } + + return (-1, ItemCount); + } } } diff --git a/src/Avalonia.Controls/Panel.cs b/src/Avalonia.Controls/Panel.cs index b7eeb065da..7a3e93ffc2 100644 --- a/src/Avalonia.Controls/Panel.cs +++ b/src/Avalonia.Controls/Panel.cs @@ -2,8 +2,12 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; + +using Avalonia.Controls.Presenters; +using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Metadata; +using Avalonia.Styling; namespace Avalonia.Controls { @@ -14,7 +18,7 @@ namespace Avalonia.Controls /// Controls can be added to a by adding them to its /// collection. All children are layed out to fill the panel. /// - public class Panel : Control, IPanel + public class Panel : Control, IPanel, IChildIndexProvider { /// /// Defines the property. @@ -160,5 +164,16 @@ namespace Avalonia.Controls var panel = control?.VisualParent as TPanel; panel?.InvalidateMeasure(); } + + (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + { + if (child is IControl control) + { + var index = Children.IndexOf(control); + return (index, Children.Count); + } + + return (-1, Children.Count); + } } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index 52f173fc71..cf5fb8ac42 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -5,6 +5,7 @@ using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; +using Avalonia.LogicalTree; using Avalonia.Styling; namespace Avalonia.Controls.Presenters @@ -12,7 +13,7 @@ namespace Avalonia.Controls.Presenters /// /// Base class for controls that present items inside an . /// - public abstract class ItemsPresenterBase : Control, IItemsPresenter, ITemplatedControl + public abstract class ItemsPresenterBase : Control, IItemsPresenter, ITemplatedControl, IChildIndexProvider { /// /// Defines the property. @@ -248,5 +249,27 @@ namespace Avalonia.Controls.Presenters { (e.NewValue as IItemsPresenterHost)?.RegisterItemsPresenter(this); } + + (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + { + int? totalCount = null; + if (Items.TryGetCountFast(out var count)) + { + totalCount = count; + } + + if (child is IControl control) + { + + if (ItemContainerGenerator is { } generator) + { + var index = ItemContainerGenerator.IndexFromContainer(control); + + return (index, totalCount); + } + } + + return (-1, totalCount); + } } } diff --git a/src/Avalonia.Controls/Utils/IEnumerableUtils.cs b/src/Avalonia.Controls/Utils/IEnumerableUtils.cs index 9614d079d9..fa5a09e245 100644 --- a/src/Avalonia.Controls/Utils/IEnumerableUtils.cs +++ b/src/Avalonia.Controls/Utils/IEnumerableUtils.cs @@ -12,23 +12,36 @@ namespace Avalonia.Controls.Utils return items.IndexOf(item) != -1; } - public static int Count(this IEnumerable items) + public static bool TryGetCountFast(this IEnumerable items, out int count) { if (items != null) { if (items is ICollection collection) { - return collection.Count; + count = collection.Count; + return true; } else if (items is IReadOnlyCollection readOnly) { - return readOnly.Count; - } - else - { - return Enumerable.Count(items.Cast()); + count = readOnly.Count; + return true; } } + + count = 0; + return false; + } + + public static int Count(this IEnumerable items) + { + if (TryGetCountFast(items, out var count)) + { + return count; + } + else if (items != null) + { + return Enumerable.Count(items.Cast()); + } else { return 0; diff --git a/src/Avalonia.Styling/Styling/NthChildSelector.cs b/src/Avalonia.Styling/Styling/NthChildSelector.cs new file mode 100644 index 0000000000..a6b91dea5f --- /dev/null +++ b/src/Avalonia.Styling/Styling/NthChildSelector.cs @@ -0,0 +1,134 @@ +#nullable enable +using System; +using System.Text; + +using Avalonia.LogicalTree; + +namespace Avalonia.Styling +{ + public interface IChildIndexProvider + { + (int Index, int? TotalCount) GetChildIndex(ILogical child); + } + + public class NthLastChildSelector : NthChildSelector + { + public NthLastChildSelector(Selector? previous, int step, int offset) : base(previous, step, offset, true) + { + } + } + + public class NthChildSelector : Selector + { + private const string NthChildSelectorName = "nth-child"; + private const string NthLastChildSelectorName = "nth-last-child"; + private readonly Selector? _previous; + private readonly bool _reversed; + + internal protected NthChildSelector(Selector? previous, int step, int offset, bool reversed) + { + _previous = previous; + Step = step; + Offset = offset; + _reversed = reversed; + } + + public NthChildSelector(Selector? previous, int step, int offset) + : this(previous, step, offset, false) + { + + } + + public override bool InTemplate => _previous?.InTemplate ?? false; + + public override bool IsCombinator => false; + + public override Type? TargetType => _previous?.TargetType; + + public int Step { get; } + public int Offset { get; } + + protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) + { + var logical = (ILogical)control; + var controlParent = logical.LogicalParent; + + if (controlParent is IChildIndexProvider childIndexProvider) + { + var (index, totalCount) = childIndexProvider.GetChildIndex(logical); + if (index < 0) + { + return SelectorMatch.NeverThisInstance; + } + + if (_reversed) + { + if (totalCount is int totalCountValue) + { + index = totalCountValue - index; + } + else + { + return SelectorMatch.NeverThisInstance; + } + } + else + { + // nth child index is 1-based + index += 1; + } + + + var n = Math.Sign(Step); + + var diff = index - Offset; + var match = diff == 0 || (Math.Sign(diff) == n && diff % Step == 0); + + return match ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; + } + else + { + return SelectorMatch.NeverThisInstance; + } + + } + + protected override Selector? MovePrevious() => _previous; + + public override string ToString() + { + var expectedCapacity = NthLastChildSelectorName.Length + 8; + var stringBuilder = new StringBuilder(_previous?.ToString(), expectedCapacity); + + stringBuilder.Append(':'); + stringBuilder.Append(_reversed ? NthLastChildSelectorName : NthChildSelectorName); + stringBuilder.Append('('); + + var hasStep = false; + if (Step != 0) + { + hasStep = true; + stringBuilder.Append(Step); + stringBuilder.Append('n'); + } + + if (Offset > 0) + { + if (hasStep) + { + stringBuilder.Append('+'); + } + stringBuilder.Append(Offset); + } + else if (Offset < 0) + { + stringBuilder.Append('-'); + stringBuilder.Append(-Offset); + } + + stringBuilder.Append(')'); + + return stringBuilder.ToString(); + } + } +} diff --git a/src/Avalonia.Styling/Styling/Selectors.cs b/src/Avalonia.Styling/Styling/Selectors.cs index 762ed7b58c..0bccccbd7c 100644 --- a/src/Avalonia.Styling/Styling/Selectors.cs +++ b/src/Avalonia.Styling/Styling/Selectors.cs @@ -123,6 +123,16 @@ namespace Avalonia.Styling return new NotSelector(previous, argument); } + public static Selector NthChild(this Selector previous, int step, int offset) + { + return new NthChildSelector(previous, step, offset); + } + + public static Selector NthLastChild(this Selector previous, int step, int offset) + { + return new NthLastChildSelector(previous, step, offset); + } + /// /// Returns a selector which matches a type. /// diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs index b81d25d613..dfabd66d17 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs @@ -97,6 +97,12 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers case SelectorGrammar.NotSyntax not: result = new XamlIlNotSelector(result, Create(not.Argument, typeResolver)); break; + case SelectorGrammar.NthChildSyntax nth: + result = new XamlIlNthChildSelector(result, nth.Step, nth.Offset, XamlIlNthChildSelector.SelectorType.NthChild); + break; + case SelectorGrammar.NthLastChildSyntax nth: + result = new XamlIlNthChildSelector(result, nth.Step, nth.Offset, XamlIlNthChildSelector.SelectorType.NthLastChild); + break; case SelectorGrammar.CommaSyntax comma: if (results == null) results = new XamlIlOrSelectorNode(node, selectorType); @@ -273,6 +279,35 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers } } + class XamlIlNthChildSelector : XamlIlSelectorNode + { + private readonly int _step; + private readonly int _offset; + private readonly SelectorType _type; + + public enum SelectorType + { + NthChild, + NthLastChild + } + + public XamlIlNthChildSelector(XamlIlSelectorNode previous, int step, int offset, SelectorType type) : base(previous) + { + _step = step; + _offset = offset; + _type = type; + } + + public override IXamlType TargetType => Previous?.TargetType; + protected override void DoEmit(XamlEmitContext context, IXamlILEmitter codeGen) + { + codeGen.Ldc_I4(_step); + codeGen.Ldc_I4(_offset); + EmitCall(context, codeGen, + m => m.Name == _type.ToString() && m.Parameters.Count == 3); + } + } + class XamlIlPropertyEqualsSelector : XamlIlSelectorNode { public XamlIlPropertyEqualsSelector(XamlIlSelectorNode previous, diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index 9d03341f92..56e64329b7 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -160,11 +160,13 @@ namespace Avalonia.Markup.Parsers if (identifier.IsEmpty) { - throw new ExpressionParseException(r.Position, "Expected class name or is selector after ':'."); + throw new ExpressionParseException(r.Position, "Expected class name, is, nth-child or nth-last-child selector after ':'."); } const string IsKeyword = "is"; const string NotKeyword = "not"; + const string NthChildKeyword = "nth-child"; + const string NthLastChildKeyword = "nth-last-child"; if (identifier.SequenceEqual(IsKeyword.AsSpan()) && r.TakeIf('(')) { @@ -181,6 +183,20 @@ namespace Avalonia.Markup.Parsers var syntax = new NotSyntax { Argument = argument }; return (State.Middle, syntax); } + if (identifier.SequenceEqual(NthChildKeyword.AsSpan()) && r.TakeIf('(')) + { + var (step, offset) = ParseNthChildArguments(ref r); + + var syntax = new NthChildSyntax { Step = step, Offset = offset }; + return (State.Middle, syntax); + } + if (identifier.SequenceEqual(NthLastChildKeyword.AsSpan()) && r.TakeIf('(')) + { + var (step, offset) = ParseNthChildArguments(ref r); + + var syntax = new NthLastChildSyntax { Step = step, Offset = offset }; + return (State.Middle, syntax); + } else { return ( @@ -191,7 +207,6 @@ namespace Avalonia.Markup.Parsers }); } } - private static (State, ISyntax?) ParseTraversal(ref CharacterReader r) { r.SkipWhitespace(); @@ -302,6 +317,70 @@ namespace Avalonia.Markup.Parsers return syntax; } + private static (int step, int offset) ParseNthChildArguments(ref CharacterReader r) + { + int step = 0; + int offset = 0; + + if (r.Peek == 'o') + { + var constArg = r.TakeUntil(')').ToString().Trim(); + if (constArg.Equals("odd", StringComparison.Ordinal)) + { + step = 2; + offset = 1; + } + else + { + throw new ExpressionParseException(r.Position, $"Expected nth-child(odd). Actual '{constArg}'."); + } + } + else if (r.Peek == 'e') + { + var constArg = r.TakeUntil(')').ToString().Trim(); + if (constArg.Equals("even", StringComparison.Ordinal)) + { + step = 2; + offset = 0; + } + else + { + throw new ExpressionParseException(r.Position, $"Expected nth-child(even). Actual '{constArg}'."); + } + } + else + { + var stepOrOffsetSpan = r.TakeWhile(c => c != ')' && c != 'n'); + if (!int.TryParse(stepOrOffsetSpan.ToString().Trim(), out var stepOrOffset)) + { + throw new ExpressionParseException(r.Position, "Couldn't parse nth-child step or offset value. Integer was expected."); + } + + if (r.Peek == ')') + { + step = 0; + offset = stepOrOffset; + } + else + { + step = stepOrOffset; + + r.Skip(1); // skip 'n' + var offsetSpan = r.TakeUntil(')').TrimStart(); + + if (offsetSpan.Length != 0 + && !int.TryParse(offsetSpan.ToString().Trim(), out offset)) + { + throw new ExpressionParseException(r.Position, "Couldn't parse nth-child offset value. Integer was expected."); + } + } + } + + Expect(ref r, ')'); + + return (step, offset); + } + private static void Expect(ref CharacterReader r, char c) { if (r.End) @@ -419,6 +498,28 @@ namespace Avalonia.Markup.Parsers } } + public class NthChildSyntax : ISyntax + { + public int Offset { get; set; } + public int Step { get; set; } + + public override bool Equals(object? obj) + { + return (obj is NthChildSyntax nth) && nth.Offset == Offset && nth.Step == Step; + } + } + + public class NthLastChildSyntax : ISyntax + { + public int Offset { get; set; } + public int Step { get; set; } + + public override bool Equals(object? obj) + { + return (obj is NthLastChildSyntax nth) && nth.Offset == Offset && nth.Step == Step; + } + } + public class CommaSyntax : ISyntax { public override bool Equals(object? obj) diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs index 92ba744ee1..11fb287d46 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs @@ -104,6 +104,12 @@ namespace Avalonia.Markup.Parsers case SelectorGrammar.NotSyntax not: result = result.Not(x => Create(not.Argument)); break; + case SelectorGrammar.NthChildSyntax nth: + result = result.NthChild(nth.Step, nth.Offset); + break; + case SelectorGrammar.NthLastChildSyntax nth: + result = result.NthLastChild(nth.Step, nth.Offset); + break; case SelectorGrammar.CommaSyntax comma: if (results == null) { diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs index 03f1120796..543d44c492 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs @@ -236,6 +236,96 @@ namespace Avalonia.Markup.UnitTests.Parsers result); } + [Fact] + public void OfType_NthChild() + { + var result = SelectorGrammar.Parse("Button:nth-child(2n+1)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NthChildSyntax() + { + Step = 2, + Offset = 1 + } + }, + result); + } + + [Fact] + public void OfType_NthChild_Without_Offset() + { + var result = SelectorGrammar.Parse("Button:nth-child(2147483647n)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NthChildSyntax() + { + Step = int.MaxValue, + Offset = 0 + } + }, + result); + } + + [Fact] + public void OfType_NthLastChild() + { + var result = SelectorGrammar.Parse("Button:nth-last-child(2n+1)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NthLastChildSyntax() + { + Step = 2, + Offset = 1 + } + }, + result); + } + + [Fact] + public void OfType_NthChild_Odd() + { + var result = SelectorGrammar.Parse("Button:nth-child(odd)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NthChildSyntax() + { + Step = 2, + Offset = 1 + } + }, + result); + } + + [Fact] + public void OfType_NthChild_Even() + { + var result = SelectorGrammar.Parse("Button:nth-child(even)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NthChildSyntax() + { + Step = 2, + Offset = 0 + } + }, + result); + } + [Fact] public void Is_Descendent_Not_OfType_Class() { diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index 06b494c3d8..3824b79708 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -267,6 +267,65 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml } } + [Fact] + public void Style_Can_Use_NthChild_Selector() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + + + + +"; + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var b1 = window.FindControl("b1"); + var b2 = window.FindControl("b2"); + + Assert.Equal(Colors.Red, ((ISolidColorBrush)b1.Background).Color); + Assert.Null(b2.Background); + } + } + + [Fact] + public void Style_Can_Use_NthChild_Selector_After_Reoder() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + + + + +"; + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + + var parent = window.FindControl("parent"); + var b1 = window.FindControl("b1"); + var b2 = window.FindControl("b2"); + + parent.Children.Remove(b1); + parent.Children.Add(b1); + + Assert.Null(b1.Background); + Assert.Equal(Colors.Red, ((ISolidColorBrush)b2.Background).Color); + } + } + [Fact] public void Style_Can_Use_Or_Selector_1() { diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs new file mode 100644 index 0000000000..b72e980821 --- /dev/null +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs @@ -0,0 +1,217 @@ +using Avalonia.Controls; +using Xunit; + +namespace Avalonia.Styling.UnitTests +{ + public class SelectorTests_NthChild + { + [Theory] + [InlineData(2, 0, ":nth-child(2n)")] + [InlineData(2, 1, ":nth-child(2n+1)")] + [InlineData(1, 0, ":nth-child(1n)")] + [InlineData(4, -1, ":nth-child(4n-1)")] + [InlineData(0, 1, ":nth-child(1)")] + [InlineData(0, -1, ":nth-child(-1)")] + [InlineData(int.MaxValue, int.MinValue + 1, ":nth-child(2147483647n-2147483647)")] + public void Not_Selector_Should_Have_Correct_String_Representation(int step, int offset, string expected) + { + var target = default(Selector).NthChild(step, offset); + + Assert.Equal(expected, target.ToString()); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(2, 0); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(2, 1); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(4, -1); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Singular_Step() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(1, 2); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(1, -1); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(0, 2); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthChild(0, -2); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Previous_Selector() + { + Border b1, b2; + Button b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new Control[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Button(), + b4 = new Button() + }); + + var previous = default(Selector).OfType(); + var target = previous.NthChild(2, 0); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + { + Border b1; + var contentControl = new ContentControl(); + contentControl.Content = b1 = new Border(); + + var target = default(Selector).NthChild(1, 0); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + } + + [Fact] + public void Returns_Correct_TargetType() + { + var target = new NthChildSelector(default(Selector).OfType(), 1, 0); + + Assert.Equal(typeof(Control1), target.TargetType); + } + + public class Control1 : Control + { + } + } +} diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs new file mode 100644 index 0000000000..3698e07d3e --- /dev/null +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs @@ -0,0 +1,217 @@ +using Avalonia.Controls; +using Xunit; + +namespace Avalonia.Styling.UnitTests +{ + public class SelectorTests_NthLastChild + { + [Theory] + [InlineData(2, 0, ":nth-last-child(2n)")] + [InlineData(2, 1, ":nth-last-child(2n+1)")] + [InlineData(1, 0, ":nth-last-child(1n)")] + [InlineData(4, -1, ":nth-last-child(4n-1)")] + [InlineData(0, 1, ":nth-last-child(1)")] + [InlineData(0, -1, ":nth-last-child(-1)")] + [InlineData(int.MaxValue, int.MinValue + 1, ":nth-last-child(2147483647n-2147483647)")] + public void Not_Selector_Should_Have_Correct_String_Representation(int step, int offset, string expected) + { + var target = default(Selector).NthLastChild(step, offset); + + Assert.Equal(expected, target.ToString()); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(2, 0); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(2, 1); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(4, -1); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Singular_Step() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(1, 2); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(1, -2); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(0, 2); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() + { + Border b1, b2, b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Border(), + b4 = new Border() + }); + + var target = default(Selector).NthLastChild(0, -2); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Match_Control_In_Panel_With_Previous_Selector() + { + Border b1, b2; + Button b3, b4; + var panel = new StackPanel(); + panel.Children.AddRange(new Control[] + { + b1 = new Border(), + b2 = new Border(), + b3 = new Button(), + b4 = new Button() + }); + + var previous = default(Selector).OfType(); + var target = previous.NthLastChild(2, 0); + + Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result); + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result); + } + + [Fact] + public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + { + Border b1; + var contentControl = new ContentControl(); + contentControl.Content = b1 = new Border(); + + var target = default(Selector).NthLastChild(1, 0); + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + } + + [Fact] + public void Returns_Correct_TargetType() + { + var target = new NthLastChildSelector(default(Selector).OfType(), 1, 0); + + Assert.Equal(typeof(Control1), target.TargetType); + } + + public class Control1 : Control + { + } + } +} From e5ca5c38e8c6cff3f0fd6494578b83a8f4df6d22 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 10 Sep 2021 02:06:02 -0400 Subject: [PATCH 12/96] Update IChildIndexProvider interface with ChildIndexChanged and implement in on items controls --- .../Pages/ItemsRepeaterPage.xaml | 19 ++- src/Avalonia.Controls/ItemsControl.cs | 39 ++++-- src/Avalonia.Controls/Panel.cs | 21 ++-- .../Presenters/ItemsPresenterBase.cs | 43 ++++--- .../Repeater/ItemsRepeater.cs | 29 ++++- .../LogicalTree/ChildIndexChangedEventArgs.cs | 23 ++++ .../LogicalTree/IChildIndexProvider.cs | 14 +++ .../Styling/Activators/NthChildActivator.cs | 56 +++++++++ .../Styling/NthChildSelector.cs | 70 +++++------ .../Styling/NthLastChildSelector.cs | 11 ++ .../Xaml/StyleTests.cs | 119 +++++++++++++++++- .../SelectorTests_NthChild.cs | 85 +++++++------ .../SelectorTests_NthLastChild.cs | 84 +++++++------ .../StyleActivatorExtensions.cs | 7 +- 14 files changed, 456 insertions(+), 164 deletions(-) create mode 100644 src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs create mode 100644 src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs create mode 100644 src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs create mode 100644 src/Avalonia.Styling/Styling/NthLastChildSelector.cs diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml index 392ccb57c3..93f3c33434 100644 --- a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml @@ -1,17 +1,28 @@ + + + + + - - diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 9c86aeb0c8..7b28335a6d 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -57,6 +57,7 @@ namespace Avalonia.Controls private IEnumerable _items = new AvaloniaList(); private int _itemCount; private IItemContainerGenerator _itemContainerGenerator; + private EventHandler _childIndexChanged; /// /// Initializes static members of the class. @@ -146,11 +147,30 @@ namespace Avalonia.Controls protected set; } + int? IChildIndexProvider.TotalCount => (Presenter as IChildIndexProvider)?.TotalCount ?? ItemCount; + + event EventHandler IChildIndexProvider.ChildIndexChanged + { + add => _childIndexChanged += value; + remove => _childIndexChanged -= value; + } + /// void IItemsPresenterHost.RegisterItemsPresenter(IItemsPresenter presenter) { + if (Presenter is IChildIndexProvider oldInnerProvider) + { + oldInnerProvider.ChildIndexChanged -= PresenterChildIndexChanged; + } + Presenter = presenter; ItemContainerGenerator.Clear(); + + if (Presenter is IChildIndexProvider innerProvider) + { + innerProvider.ChildIndexChanged += PresenterChildIndexChanged; + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs()); + } } void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) @@ -508,20 +528,15 @@ namespace Avalonia.Controls return null; } - (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + private void PresenterChildIndexChanged(object sender, ChildIndexChangedEventArgs e) { - if (Presenter is IChildIndexProvider innerProvider) - { - return innerProvider.GetChildIndex(child); - } - - if (child is IControl control) - { - var index = ItemContainerGenerator.IndexFromContainer(control); - return (index, ItemCount); - } + _childIndexChanged?.Invoke(this, e); + } - return (-1, ItemCount); + int IChildIndexProvider.GetChildIndex(ILogical child) + { + return Presenter is IChildIndexProvider innerProvider + ? innerProvider.GetChildIndex(child) : -1; } } } diff --git a/src/Avalonia.Controls/Panel.cs b/src/Avalonia.Controls/Panel.cs index 7a3e93ffc2..9c93126506 100644 --- a/src/Avalonia.Controls/Panel.cs +++ b/src/Avalonia.Controls/Panel.cs @@ -34,6 +34,8 @@ namespace Avalonia.Controls AffectsRender(BackgroundProperty); } + private EventHandler _childIndexChanged; + /// /// Initializes a new instance of the class. /// @@ -57,6 +59,14 @@ namespace Avalonia.Controls set { SetValue(BackgroundProperty, value); } } + int? IChildIndexProvider.TotalCount => Children.Count; + + event EventHandler IChildIndexProvider.ChildIndexChanged + { + add => _childIndexChanged += value; + remove => _childIndexChanged -= value; + } + /// /// Renders the visual to a . /// @@ -141,6 +151,7 @@ namespace Avalonia.Controls throw new NotSupportedException(); } + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs()); InvalidateMeasureOnChildrenChanged(); } @@ -165,15 +176,9 @@ namespace Avalonia.Controls panel?.InvalidateMeasure(); } - (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + int IChildIndexProvider.GetChildIndex(ILogical child) { - if (child is IControl control) - { - var index = Children.IndexOf(control); - return (index, Children.Count); - } - - return (-1, Children.Count); + return child is IControl control ? Children.IndexOf(control) : -1; } } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index cf5fb8ac42..d58ef2e510 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -1,6 +1,8 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Linq; + using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Templates; @@ -37,6 +39,7 @@ namespace Avalonia.Controls.Presenters private IDisposable _itemsSubscription; private bool _createdPanel; private IItemContainerGenerator _generator; + private EventHandler _childIndexChanged; /// /// Initializes static members of the class. @@ -130,6 +133,14 @@ namespace Avalonia.Controls.Presenters protected bool IsHosted => TemplatedParent is IItemsPresenterHost; + int? IChildIndexProvider.TotalCount => Items.TryGetCountFast(out var count) ? count : null; + + event EventHandler IChildIndexProvider.ChildIndexChanged + { + add => _childIndexChanged += value; + remove => _childIndexChanged -= value; + } + /// public override sealed void ApplyTemplate() { @@ -170,9 +181,21 @@ namespace Avalonia.Controls.Presenters result.ItemTemplate = ItemTemplate; } + result.Materialized += ContainerActionHandler; + result.Dematerialized += ContainerActionHandler; + result.Recycled += ContainerActionHandler; + return result; } + private void ContainerActionHandler(object sender, ItemContainerEventArgs e) + { + for (var i = 0; i < e.Containers.Count; i++) + { + _childIndexChanged?.Invoke(sender, new ChildIndexChangedEventArgs(e.Containers[i].ContainerControl)); + } + } + /// protected override Size MeasureOverride(Size availableSize) { @@ -250,26 +273,16 @@ namespace Avalonia.Controls.Presenters (e.NewValue as IItemsPresenterHost)?.RegisterItemsPresenter(this); } - (int Index, int? TotalCount) IChildIndexProvider.GetChildIndex(ILogical child) + int IChildIndexProvider.GetChildIndex(ILogical child) { - int? totalCount = null; - if (Items.TryGetCountFast(out var count)) - { - totalCount = count; - } - - if (child is IControl control) + if (child is IControl control && ItemContainerGenerator is { } generator) { + var index = ItemContainerGenerator.IndexFromContainer(control); - if (ItemContainerGenerator is { } generator) - { - var index = ItemContainerGenerator.IndexFromContainer(control); - - return (index, totalCount); - } + return index; } - return (-1, totalCount); + return -1; } } } diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs index 0ff8fcbd28..6d89a70670 100644 --- a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs +++ b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs @@ -6,10 +6,13 @@ using System; using System.Collections; using System.Collections.Specialized; + using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Logging; +using Avalonia.LogicalTree; +using Avalonia.Styling; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -19,7 +22,7 @@ namespace Avalonia.Controls /// Represents a data-driven collection control that incorporates a flexible layout system, /// custom views, and virtualization. /// - public class ItemsRepeater : Panel + public class ItemsRepeater : Panel, IChildIndexProvider { /// /// Defines the property. @@ -61,8 +64,9 @@ namespace Avalonia.Controls private readonly ViewportManager _viewportManager; private IEnumerable _items; private VirtualizingLayoutContext _layoutContext; - private NotifyCollectionChangedEventArgs _processingItemsSourceChange; + private EventHandler _childIndexChanged; private bool _isLayoutInProgress; + private NotifyCollectionChangedEventArgs _processingItemsSourceChange; private ItemsRepeaterElementPreparedEventArgs _elementPreparedArgs; private ItemsRepeaterElementClearingEventArgs _elementClearingArgs; private ItemsRepeaterElementIndexChangedEventArgs _elementIndexChangedArgs; @@ -163,6 +167,21 @@ namespace Avalonia.Controls } } + int? IChildIndexProvider.TotalCount => ItemsSourceView.Count; + + event EventHandler IChildIndexProvider.ChildIndexChanged + { + add => _childIndexChanged += value; + remove => _childIndexChanged -= value; + } + + int IChildIndexProvider.GetChildIndex(ILogical child) + { + return child is IControl control + ? GetElementIndex(control) + : -1; + } + /// /// Occurs each time an element is cleared and made available to be re-used. /// @@ -545,6 +564,8 @@ namespace Avalonia.Controls ElementPrepared(this, _elementPreparedArgs); } + + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element)); } internal void OnElementClearing(IControl element) @@ -562,6 +583,8 @@ namespace Avalonia.Controls ElementClearing(this, _elementClearingArgs); } + + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element)); } internal void OnElementIndexChanged(IControl element, int oldIndex, int newIndex) @@ -579,6 +602,8 @@ namespace Avalonia.Controls ElementIndexChanged(this, _elementIndexChangedArgs); } + + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element)); } private void OnDataSourcePropertyChanged(ItemsSourceView oldValue, ItemsSourceView newValue) diff --git a/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs b/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs new file mode 100644 index 0000000000..1c90851e13 --- /dev/null +++ b/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs @@ -0,0 +1,23 @@ +#nullable enable +using System; + +namespace Avalonia.LogicalTree +{ + public class ChildIndexChangedEventArgs : EventArgs + { + public ChildIndexChangedEventArgs() + { + } + + public ChildIndexChangedEventArgs(ILogical child) + { + Child = child; + } + + /// + /// Logical child which index was changed. + /// If null, all children should be reset. + /// + public ILogical? Child { get; } + } +} diff --git a/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs new file mode 100644 index 0000000000..fdba99baa2 --- /dev/null +++ b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; + +namespace Avalonia.LogicalTree +{ + public interface IChildIndexProvider + { + int GetChildIndex(ILogical child); + + int? TotalCount { get; } + + event EventHandler? ChildIndexChanged; + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs new file mode 100644 index 0000000000..34cca1a396 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs @@ -0,0 +1,56 @@ +#nullable enable +using System; + +using Avalonia.LogicalTree; + +namespace Avalonia.Styling.Activators +{ + /// + /// An which is active when control's index was changed. + /// + internal sealed class NthChildActivator : StyleActivatorBase + { + private readonly ILogical _control; + private readonly IChildIndexProvider _provider; + private readonly int _step; + private readonly int _offset; + private readonly bool _reversed; + private EventHandler? _childIndexChangedHandler; + + public NthChildActivator( + ILogical control, + IChildIndexProvider provider, + int step, int offset, bool reversed) + { + _control = control; + _provider = provider; + _step = step; + _offset = offset; + _reversed = reversed; + } + + private EventHandler ChildIndexChangedHandler => _childIndexChangedHandler ??= ChildIndexChanged; + + protected override void Initialize() + { + PublishNext(IsMatching()); + _provider.ChildIndexChanged += ChildIndexChangedHandler; + } + + protected override void Deinitialize() + { + _provider.ChildIndexChanged -= ChildIndexChangedHandler; + } + + private void ChildIndexChanged(object sender, ChildIndexChangedEventArgs e) + { + if (e.Child is null + || e.Child == _control) + { + PublishNext(IsMatching()); + } + } + + private bool IsMatching() => NthChildSelector.Evaluate(_control, _provider, _step, _offset, _reversed).IsMatch; + } +} diff --git a/src/Avalonia.Styling/Styling/NthChildSelector.cs b/src/Avalonia.Styling/Styling/NthChildSelector.cs index a6b91dea5f..16b97e22f6 100644 --- a/src/Avalonia.Styling/Styling/NthChildSelector.cs +++ b/src/Avalonia.Styling/Styling/NthChildSelector.cs @@ -3,21 +3,10 @@ using System; using System.Text; using Avalonia.LogicalTree; +using Avalonia.Styling.Activators; namespace Avalonia.Styling { - public interface IChildIndexProvider - { - (int Index, int? TotalCount) GetChildIndex(ILogical child); - } - - public class NthLastChildSelector : NthChildSelector - { - public NthLastChildSelector(Selector? previous, int step, int offset) : base(previous, step, offset, true) - { - } - } - public class NthChildSelector : Selector { private const string NthChildSelectorName = "nth-child"; @@ -55,42 +44,49 @@ namespace Avalonia.Styling if (controlParent is IChildIndexProvider childIndexProvider) { - var (index, totalCount) = childIndexProvider.GetChildIndex(logical); - if (index < 0) - { - return SelectorMatch.NeverThisInstance; - } + return subscribe + ? new SelectorMatch(new NthChildActivator(logical, childIndexProvider, Step, Offset, _reversed)) + : Evaluate(logical, childIndexProvider, Step, Offset, _reversed); + } + else + { + return SelectorMatch.NeverThisInstance; + } + } + + internal static SelectorMatch Evaluate( + ILogical logical, IChildIndexProvider childIndexProvider, + int step, int offset, bool reversed) + { + var index = childIndexProvider.GetChildIndex(logical); + if (index < 0) + { + return SelectorMatch.NeverThisInstance; + } - if (_reversed) + if (reversed) + { + if (childIndexProvider.TotalCount is int totalCountValue) { - if (totalCount is int totalCountValue) - { - index = totalCountValue - index; - } - else - { - return SelectorMatch.NeverThisInstance; - } + index = totalCountValue - index; } else { - // nth child index is 1-based - index += 1; + return SelectorMatch.NeverThisInstance; } - - - var n = Math.Sign(Step); - - var diff = index - Offset; - var match = diff == 0 || (Math.Sign(diff) == n && diff % Step == 0); - - return match ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; } else { - return SelectorMatch.NeverThisInstance; + // nth child index is 1-based + index += 1; } + var n = Math.Sign(step); + + var diff = index - offset; + var match = diff == 0 || (Math.Sign(diff) == n && diff % step == 0); + + return match ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; } protected override Selector? MovePrevious() => _previous; diff --git a/src/Avalonia.Styling/Styling/NthLastChildSelector.cs b/src/Avalonia.Styling/Styling/NthLastChildSelector.cs new file mode 100644 index 0000000000..ff7cf0faa1 --- /dev/null +++ b/src/Avalonia.Styling/Styling/NthLastChildSelector.cs @@ -0,0 +1,11 @@ +#nullable enable + +namespace Avalonia.Styling +{ + public class NthLastChildSelector : NthChildSelector + { + public NthLastChildSelector(Selector? previous, int step, int offset) : base(previous, step, offset, true) + { + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index 3824b79708..ee633ee66f 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -1,3 +1,6 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; using System.Xml; using Avalonia.Controls; using Avalonia.Markup.Data; @@ -289,7 +292,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml var b1 = window.FindControl("b1"); var b2 = window.FindControl("b2"); - Assert.Equal(Colors.Red, ((ISolidColorBrush)b1.Background).Color); + Assert.Equal(Brushes.Red, b1.Background); Assert.Null(b2.Background); } } @@ -303,7 +306,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml - @@ -318,11 +321,119 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml var b1 = window.FindControl("b1"); var b2 = window.FindControl("b2"); + Assert.Null(b1.Background); + Assert.Equal(Brushes.Red, b2.Background); + parent.Children.Remove(b1); - parent.Children.Add(b1); Assert.Null(b1.Background); - Assert.Equal(Colors.Red, ((ISolidColorBrush)b2.Background).Color); + Assert.Null(b2.Background); + + parent.Children.Add(b1); + + Assert.Equal(Brushes.Red, b1.Background); + Assert.Null(b2.Background); + } + } + + + [Fact] + public void Style_Can_Use_NthChild_Selector_With_ListBox() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + +"; + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var collection = new ObservableCollection() + { + Brushes.Red, Brushes.Green, Brushes.Blue + }; + + var list = window.FindControl("list"); + list.VirtualizationMode = ItemVirtualizationMode.Simple; + list.Items = collection; + + window.Show(); + + var items = list.Presenter.Panel.Children.Cast(); + ListBoxItem At(int index) => items.ElementAt(index); + + Assert.Equal(Brushes.Transparent, At(0).Background); + Assert.Equal(Brushes.Green, At(1).Background); + Assert.Equal(Brushes.Transparent, At(2).Background); + + collection.Remove(Brushes.Green); + + Assert.Equal(Brushes.Transparent, At(0).Background); + Assert.Equal(Brushes.Blue, At(1).Background); + + collection.Add(Brushes.Violet); + collection.Add(Brushes.Black); + + Assert.Equal(Brushes.Transparent, At(0).Background); + Assert.Equal(Brushes.Blue, At(1).Background); + Assert.Equal(Brushes.Transparent, At(2).Background); + Assert.Equal(Brushes.Black, At(3).Background); + } + } + + [Fact] + public void Style_Can_Use_NthChild_Selector_With_ItemsRepeater() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + + +"; + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var collection = new ObservableCollection() + { + Brushes.Red, Brushes.Green, Brushes.Blue + }; + + var list = window.FindControl("list"); + list.Items = collection; + + window.Show(); + + var items = list.Children; + TextBlock At(int index) => (TextBlock)list.GetOrCreateElement(index); + + Assert.Equal(Brushes.Transparent, At(0).Foreground); + Assert.Equal(Brushes.Green, At(1).Foreground); + Assert.Equal(Brushes.Transparent, At(2).Foreground); + + collection.Remove(Brushes.Green); + + Assert.Equal(Brushes.Transparent, At(0).Foreground); + Assert.Equal(Brushes.Blue, At(1).Foreground); + + collection.Add(Brushes.Violet); + collection.Add(Brushes.Black); + + Assert.Equal(Brushes.Transparent, At(0).Foreground); + Assert.Equal(Brushes.Blue, At(1).Foreground); + Assert.Equal(Brushes.Transparent, At(2).Foreground); + Assert.Equal(Brushes.Black, At(3).Foreground); } } diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs index b72e980821..a70b3c9f29 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs @@ -1,4 +1,7 @@ +using System.Threading.Tasks; + using Avalonia.Controls; + using Xunit; namespace Avalonia.Styling.UnitTests @@ -21,7 +24,7 @@ namespace Avalonia.Styling.UnitTests } [Fact] - public void Nth_Child_Match_Control_In_Panel() + public async Task Nth_Child_Match_Control_In_Panel() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -35,14 +38,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(2, 0); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.True(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -56,14 +59,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(2, 1); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Negative_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -77,14 +80,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(4, -1); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Singular_Step() + public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -98,14 +101,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(1, 2); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.True(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -119,14 +122,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(1, -1); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.True(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -140,14 +143,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(0, 2); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() + public async Task Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -161,14 +164,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(0, -2); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Previous_Selector() + public async Task Nth_Child_Match_Control_In_Panel_With_Previous_Selector() { Border b1, b2; Button b3, b4; @@ -184,14 +187,16 @@ namespace Avalonia.Styling.UnitTests var previous = default(Selector).OfType(); var target = previous.NthChild(2, 0); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.Null(target.Match(b3).Activator); Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result); + Assert.Null(target.Match(b4).Activator); Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result); } [Fact] - public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + public async Task Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() { Border b1; var contentControl = new ContentControl(); @@ -199,7 +204,7 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(1, 0); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); } [Fact] diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs index 3698e07d3e..ed88106295 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs @@ -1,3 +1,5 @@ +using System.Threading.Tasks; + using Avalonia.Controls; using Xunit; @@ -21,7 +23,7 @@ namespace Avalonia.Styling.UnitTests } [Fact] - public void Nth_Child_Match_Control_In_Panel() + public async Task Nth_Child_Match_Control_In_Panel() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -35,14 +37,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(2, 0); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -56,14 +58,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(2, 1); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.True(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Negative_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -77,14 +79,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(4, -1); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Singular_Step() + public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -98,14 +100,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(1, 2); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Singular_Step_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -119,14 +121,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(1, -2); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b4).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.True(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.True(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() + public async Task Nth_Child_Match_Control_In_Panel_With_Zero_Step_With_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -140,14 +142,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(0, 2); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.True(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() + public async Task Nth_Child_Doesnt_Match_Control_In_Panel_With_Zero_Step_With_Negative_Offset() { Border b1, b2, b3, b4; var panel = new StackPanel(); @@ -161,14 +163,14 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(0, -2); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b3).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b4).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.False(await target.Match(b3).Activator!.Take(1)); + Assert.False(await target.Match(b4).Activator!.Take(1)); } [Fact] - public void Nth_Child_Match_Control_In_Panel_With_Previous_Selector() + public async Task Nth_Child_Match_Control_In_Panel_With_Previous_Selector() { Border b1, b2; Button b3, b4; @@ -184,14 +186,16 @@ namespace Avalonia.Styling.UnitTests var previous = default(Selector).OfType(); var target = previous.NthLastChild(2, 0); - Assert.Equal(SelectorMatchResult.AlwaysThisInstance, target.Match(b1).Result); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b2).Result); + Assert.True(await target.Match(b1).Activator!.Take(1)); + Assert.False(await target.Match(b2).Activator!.Take(1)); + Assert.Null(target.Match(b3).Activator); Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b3).Result); + Assert.Null(target.Match(b4).Activator); Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(b4).Result); } [Fact] - public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + public async Task Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() { Border b1; var contentControl = new ContentControl(); @@ -199,7 +203,7 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(1, 0); - Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(b1).Result); + Assert.False(await target.Match(b1).Activator!.Take(1)); } [Fact] diff --git a/tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs b/tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs index eb3dabce0b..22f4db79d1 100644 --- a/tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs +++ b/tests/Avalonia.Styling.UnitTests/StyleActivatorExtensions.cs @@ -20,13 +20,17 @@ namespace Avalonia.Styling.UnitTests public static IObservable ToObservable(this IStyleActivator activator) { + if (activator == null) + { + throw new ArgumentNullException(nameof(activator)); + } + return new ObservableAdapter(activator); } private class ObservableAdapter : LightweightObservableBase, IStyleActivatorSink { private readonly IStyleActivator _source; - private bool _value; public ObservableAdapter(IStyleActivator source) => _source = source; protected override void Initialize() => _source.Subscribe(this); @@ -34,7 +38,6 @@ namespace Avalonia.Styling.UnitTests void IStyleActivatorSink.OnNext(bool value, int tag) { - _value = value; PublishNext(value); } } From 031e8ac2f0e45c91150f48f1d1d2393c7ae9606c Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 16 Oct 2021 22:15:16 -0400 Subject: [PATCH 13/96] Complete --- .../Pages/ItemsRepeaterPage.xaml | 5 ++ samples/ControlCatalog/Pages/ListBoxPage.xaml | 7 +- .../Presenters/ItemsPresenterBase.cs | 7 +- .../LogicalTree/ChildIndexChangedEventArgs.cs | 3 + .../LogicalTree/IChildIndexProvider.cs | 18 +++++ .../Styling/Activators/NthChildActivator.cs | 7 +- .../Styling/NthChildSelector.cs | 12 ++++ .../Styling/NthLastChildSelector.cs | 12 ++++ src/Avalonia.Styling/Styling/Selectors.cs | 6 ++ .../Xaml/StyleTests.cs | 71 +++++++++++++------ .../SelectorTests_NthChild.cs | 4 +- .../SelectorTests_NthLastChild.cs | 4 +- 12 files changed, 125 insertions(+), 31 deletions(-) diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml index 93f3c33434..4d0bd663df 100644 --- a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml @@ -12,6 +12,11 @@ + diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 897134badb..cb29f54c94 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -3,8 +3,13 @@ x:Class="ControlCatalog.Pages.ListBoxPage"> - + diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index d58ef2e510..b92af1eb9c 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Specialized; -using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Generators; @@ -133,7 +132,7 @@ namespace Avalonia.Controls.Presenters protected bool IsHosted => TemplatedParent is IItemsPresenterHost; - int? IChildIndexProvider.TotalCount => Items.TryGetCountFast(out var count) ? count : null; + int? IChildIndexProvider.TotalCount => Items.TryGetCountFast(out var count) ? count : (int?)null; event EventHandler IChildIndexProvider.ChildIndexChanged { @@ -161,6 +160,8 @@ namespace Avalonia.Controls.Presenters if (Panel != null) { ItemsChanged(e); + + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs()); } } @@ -192,7 +193,7 @@ namespace Avalonia.Controls.Presenters { for (var i = 0; i < e.Containers.Count; i++) { - _childIndexChanged?.Invoke(sender, new ChildIndexChangedEventArgs(e.Containers[i].ContainerControl)); + _childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(e.Containers[i].ContainerControl)); } } diff --git a/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs b/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs index 1c90851e13..de41f5292c 100644 --- a/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs +++ b/src/Avalonia.Styling/LogicalTree/ChildIndexChangedEventArgs.cs @@ -3,6 +3,9 @@ using System; namespace Avalonia.LogicalTree { + /// + /// Event args for event. + /// public class ChildIndexChangedEventArgs : EventArgs { public ChildIndexChangedEventArgs() diff --git a/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs index fdba99baa2..53e2199d28 100644 --- a/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs +++ b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs @@ -3,12 +3,30 @@ using System; namespace Avalonia.LogicalTree { + /// + /// Child's index and total count information provider used by list-controls (ListBox, StackPanel, etc.) + /// + /// + /// Used by nth-child and nth-last-child selectors. + /// public interface IChildIndexProvider { + /// + /// Gets child's actual index in order of the original source. + /// + /// Logical child. + /// Index or -1 if child was not found. int GetChildIndex(ILogical child); + /// + /// Total children count or null if source is infinite. + /// Some Avalonia features might not work if is null, for instance: nth-last-child selector. + /// int? TotalCount { get; } + /// + /// Notifies subscriber when child's index or total count was changed. + /// event EventHandler? ChildIndexChanged; } } diff --git a/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs index 34cca1a396..5d23d1ffd1 100644 --- a/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs +++ b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs @@ -44,7 +44,12 @@ namespace Avalonia.Styling.Activators private void ChildIndexChanged(object sender, ChildIndexChangedEventArgs e) { - if (e.Child is null + // Run matching again if: + // 1. Selector is reversed, so other item insertion/deletion might affect total count without changing subscribed item index. + // 2. e.Child is null, when all children indeces were changed. + // 3. Subscribed child index was changed. + if (_reversed + || e.Child is null || e.Child == _control) { PublishNext(IsMatching()); diff --git a/src/Avalonia.Styling/Styling/NthChildSelector.cs b/src/Avalonia.Styling/Styling/NthChildSelector.cs index 16b97e22f6..e844fb51f8 100644 --- a/src/Avalonia.Styling/Styling/NthChildSelector.cs +++ b/src/Avalonia.Styling/Styling/NthChildSelector.cs @@ -7,6 +7,12 @@ using Avalonia.Styling.Activators; namespace Avalonia.Styling { + /// + /// The :nth-child() pseudo-class matches elements based on their position in a group of siblings. + /// + /// + /// Element indices are 1-based. + /// public class NthChildSelector : Selector { private const string NthChildSelectorName = "nth-child"; @@ -22,6 +28,12 @@ namespace Avalonia.Styling _reversed = reversed; } + /// + /// Creates an instance of + /// + /// Previous selector. + /// Position step. + /// Initial index offset. public NthChildSelector(Selector? previous, int step, int offset) : this(previous, step, offset, false) { diff --git a/src/Avalonia.Styling/Styling/NthLastChildSelector.cs b/src/Avalonia.Styling/Styling/NthLastChildSelector.cs index ff7cf0faa1..6f6abbae6a 100644 --- a/src/Avalonia.Styling/Styling/NthLastChildSelector.cs +++ b/src/Avalonia.Styling/Styling/NthLastChildSelector.cs @@ -2,8 +2,20 @@ namespace Avalonia.Styling { + /// + /// The :nth-child() pseudo-class matches elements based on their position among a group of siblings, counting from the end. + /// + /// + /// Element indices are 1-based. + /// public class NthLastChildSelector : NthChildSelector { + /// + /// Creates an instance of + /// + /// Previous selector. + /// Position step. + /// Initial index offset, counting from the end. public NthLastChildSelector(Selector? previous, int step, int offset) : base(previous, step, offset, true) { } diff --git a/src/Avalonia.Styling/Styling/Selectors.cs b/src/Avalonia.Styling/Styling/Selectors.cs index 0bccccbd7c..64d0a0e96b 100644 --- a/src/Avalonia.Styling/Styling/Selectors.cs +++ b/src/Avalonia.Styling/Styling/Selectors.cs @@ -123,11 +123,17 @@ namespace Avalonia.Styling return new NotSelector(previous, argument); } + /// + /// + /// The selector. public static Selector NthChild(this Selector previous, int step, int offset) { return new NthChildSelector(previous, step, offset); } + /// + /// + /// The selector. public static Selector NthLastChild(this Selector previous, int step, int offset) { return new NthLastChildSelector(previous, step, offset); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index ee633ee66f..28960c8bf6 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; @@ -336,6 +337,45 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml } } + [Fact] + public void Style_Can_Use_NthLastChild_Selector_After_Reoder() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + + + + +"; + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + + var parent = window.FindControl("parent"); + var b1 = window.FindControl("b1"); + var b2 = window.FindControl("b2"); + + Assert.Equal(Brushes.Red, b1.Background); + Assert.Null(b2.Background); + + parent.Children.Remove(b1); + + Assert.Null(b1.Background); + Assert.Null(b2.Background); + + parent.Children.Add(b1); + + Assert.Null(b1.Background); + Assert.Equal(Brushes.Red, b2.Background); + } + } + [Fact] public void Style_Can_Use_NthChild_Selector_With_ListBox() @@ -364,25 +404,18 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml window.Show(); - var items = list.Presenter.Panel.Children.Cast(); - ListBoxItem At(int index) => items.ElementAt(index); + IEnumerable GetColors() => list.Presenter.Panel.Children.Cast().Select(t => t.Background); - Assert.Equal(Brushes.Transparent, At(0).Background); - Assert.Equal(Brushes.Green, At(1).Background); - Assert.Equal(Brushes.Transparent, At(2).Background); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Green, Brushes.Transparent }, GetColors()); collection.Remove(Brushes.Green); - Assert.Equal(Brushes.Transparent, At(0).Background); - Assert.Equal(Brushes.Blue, At(1).Background); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue }, GetColors()); collection.Add(Brushes.Violet); collection.Add(Brushes.Black); - Assert.Equal(Brushes.Transparent, At(0).Background); - Assert.Equal(Brushes.Blue, At(1).Background); - Assert.Equal(Brushes.Transparent, At(2).Background); - Assert.Equal(Brushes.Black, At(3).Background); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue, Brushes.Transparent, Brushes.Black }, GetColors()); } } @@ -415,25 +448,19 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml window.Show(); - var items = list.Children; - TextBlock At(int index) => (TextBlock)list.GetOrCreateElement(index); + IEnumerable GetColors() => Enumerable.Range(0, list.ItemsSourceView.Count) + .Select(t => (list.GetOrCreateElement(t) as TextBlock)!.Foreground); - Assert.Equal(Brushes.Transparent, At(0).Foreground); - Assert.Equal(Brushes.Green, At(1).Foreground); - Assert.Equal(Brushes.Transparent, At(2).Foreground); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Green, Brushes.Transparent }, GetColors()); collection.Remove(Brushes.Green); - Assert.Equal(Brushes.Transparent, At(0).Foreground); - Assert.Equal(Brushes.Blue, At(1).Foreground); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue }, GetColors()); collection.Add(Brushes.Violet); collection.Add(Brushes.Black); - Assert.Equal(Brushes.Transparent, At(0).Foreground); - Assert.Equal(Brushes.Blue, At(1).Foreground); - Assert.Equal(Brushes.Transparent, At(2).Foreground); - Assert.Equal(Brushes.Black, At(3).Foreground); + Assert.Equal(new[] { Brushes.Transparent, Brushes.Blue, Brushes.Transparent, Brushes.Black }, GetColors()); } } diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs index a70b3c9f29..8a8e46fc4b 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs @@ -196,7 +196,7 @@ namespace Avalonia.Styling.UnitTests } [Fact] - public async Task Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() { Border b1; var contentControl = new ContentControl(); @@ -204,7 +204,7 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthChild(1, 0); - Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.Equal(SelectorMatch.NeverThisInstance, target.Match(b1)); } [Fact] diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs index ed88106295..8d9d490724 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs @@ -195,7 +195,7 @@ namespace Avalonia.Styling.UnitTests } [Fact] - public async Task Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() + public void Nth_Child_Doesnt_Match_Control_Out_Of_Panel_Parent() { Border b1; var contentControl = new ContentControl(); @@ -203,7 +203,7 @@ namespace Avalonia.Styling.UnitTests var target = default(Selector).NthLastChild(1, 0); - Assert.False(await target.Match(b1).Activator!.Take(1)); + Assert.Equal(SelectorMatch.NeverThisInstance, target.Match(b1)); } [Fact] From f276c4ed8b992017003d94a863128effdd068fe2 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 21 Oct 2021 18:50:02 -0400 Subject: [PATCH 14/96] Changes after review --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 2 +- src/Avalonia.Controls/ItemsControl.cs | 15 ++++++++++++--- src/Avalonia.Controls/Panel.cs | 10 ++++++---- .../Presenters/ItemsPresenterBase.cs | 8 +++++--- src/Avalonia.Controls/Repeater/ItemsRepeater.cs | 10 ++++++---- .../LogicalTree/IChildIndexProvider.cs | 4 ++-- .../Styling/Activators/NthChildActivator.cs | 9 ++------- src/Avalonia.Styling/Styling/NthChildSelector.cs | 9 ++++++--- .../Xaml/StyleTests.cs | 2 -- .../SelectorTests_NthChild.cs | 2 -- .../SelectorTests_NthLastChild.cs | 1 - 11 files changed, 40 insertions(+), 32 deletions(-) diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index cb29f54c94..b36629fb2a 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -15,7 +15,7 @@ ListBox Hosts a collection of ListBoxItem. - Each 2nd item is highlighted + Each 5th item is highlighted with nth-child(5n+3) and nth-last-child(5n+4) rules. Multiple diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 7b28335a6d..1ff49326b6 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -13,7 +13,6 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; -using Avalonia.Styling; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -147,8 +146,6 @@ namespace Avalonia.Controls protected set; } - int? IChildIndexProvider.TotalCount => (Presenter as IChildIndexProvider)?.TotalCount ?? ItemCount; - event EventHandler IChildIndexProvider.ChildIndexChanged { add => _childIndexChanged += value; @@ -538,5 +535,17 @@ namespace Avalonia.Controls return Presenter is IChildIndexProvider innerProvider ? innerProvider.GetChildIndex(child) : -1; } + + bool IChildIndexProvider.TryGetTotalCount(out int count) + { + if (Presenter is IChildIndexProvider presenter + && presenter.TryGetTotalCount(out count)) + { + return true; + } + + count = ItemCount; + return true; + } } } diff --git a/src/Avalonia.Controls/Panel.cs b/src/Avalonia.Controls/Panel.cs index 9c93126506..b182f9d261 100644 --- a/src/Avalonia.Controls/Panel.cs +++ b/src/Avalonia.Controls/Panel.cs @@ -2,8 +2,6 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; - -using Avalonia.Controls.Presenters; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Metadata; @@ -59,8 +57,6 @@ namespace Avalonia.Controls set { SetValue(BackgroundProperty, value); } } - int? IChildIndexProvider.TotalCount => Children.Count; - event EventHandler IChildIndexProvider.ChildIndexChanged { add => _childIndexChanged += value; @@ -180,5 +176,11 @@ namespace Avalonia.Controls { return child is IControl control ? Children.IndexOf(control) : -1; } + + public bool TryGetTotalCount(out int count) + { + count = Children.Count; + return true; + } } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index b92af1eb9c..aeead7bfd0 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Specialized; - using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Templates; @@ -132,8 +131,6 @@ namespace Avalonia.Controls.Presenters protected bool IsHosted => TemplatedParent is IItemsPresenterHost; - int? IChildIndexProvider.TotalCount => Items.TryGetCountFast(out var count) ? count : (int?)null; - event EventHandler IChildIndexProvider.ChildIndexChanged { add => _childIndexChanged += value; @@ -285,5 +282,10 @@ namespace Avalonia.Controls.Presenters return -1; } + + bool IChildIndexProvider.TryGetTotalCount(out int count) + { + return Items.TryGetCountFast(out count); + } } } diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs index 6d89a70670..ecc0fa3a48 100644 --- a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs +++ b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs @@ -6,13 +6,11 @@ using System; using System.Collections; using System.Collections.Specialized; - using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Logging; using Avalonia.LogicalTree; -using Avalonia.Styling; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -167,8 +165,6 @@ namespace Avalonia.Controls } } - int? IChildIndexProvider.TotalCount => ItemsSourceView.Count; - event EventHandler IChildIndexProvider.ChildIndexChanged { add => _childIndexChanged += value; @@ -182,6 +178,12 @@ namespace Avalonia.Controls : -1; } + bool IChildIndexProvider.TryGetTotalCount(out int count) + { + count = ItemsSourceView.Count; + return true; + } + /// /// Occurs each time an element is cleared and made available to be re-used. /// diff --git a/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs index 53e2199d28..7fcd73273c 100644 --- a/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs +++ b/src/Avalonia.Styling/LogicalTree/IChildIndexProvider.cs @@ -20,9 +20,9 @@ namespace Avalonia.LogicalTree /// /// Total children count or null if source is infinite. - /// Some Avalonia features might not work if is null, for instance: nth-last-child selector. + /// Some Avalonia features might not work if returns false, for instance: nth-last-child selector. /// - int? TotalCount { get; } + bool TryGetTotalCount(out int count); /// /// Notifies subscriber when child's index or total count was changed. diff --git a/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs index 5d23d1ffd1..803809a8ce 100644 --- a/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs +++ b/src/Avalonia.Styling/Styling/Activators/NthChildActivator.cs @@ -1,6 +1,4 @@ #nullable enable -using System; - using Avalonia.LogicalTree; namespace Avalonia.Styling.Activators @@ -15,7 +13,6 @@ namespace Avalonia.Styling.Activators private readonly int _step; private readonly int _offset; private readonly bool _reversed; - private EventHandler? _childIndexChangedHandler; public NthChildActivator( ILogical control, @@ -29,17 +26,15 @@ namespace Avalonia.Styling.Activators _reversed = reversed; } - private EventHandler ChildIndexChangedHandler => _childIndexChangedHandler ??= ChildIndexChanged; - protected override void Initialize() { PublishNext(IsMatching()); - _provider.ChildIndexChanged += ChildIndexChangedHandler; + _provider.ChildIndexChanged += ChildIndexChanged; } protected override void Deinitialize() { - _provider.ChildIndexChanged -= ChildIndexChangedHandler; + _provider.ChildIndexChanged -= ChildIndexChanged; } private void ChildIndexChanged(object sender, ChildIndexChangedEventArgs e) diff --git a/src/Avalonia.Styling/Styling/NthChildSelector.cs b/src/Avalonia.Styling/Styling/NthChildSelector.cs index e844fb51f8..aff34ea17c 100644 --- a/src/Avalonia.Styling/Styling/NthChildSelector.cs +++ b/src/Avalonia.Styling/Styling/NthChildSelector.cs @@ -1,7 +1,6 @@ #nullable enable using System; using System.Text; - using Avalonia.LogicalTree; using Avalonia.Styling.Activators; @@ -51,7 +50,11 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) { - var logical = (ILogical)control; + if (!(control is ILogical logical)) + { + return SelectorMatch.NeverThisType; + } + var controlParent = logical.LogicalParent; if (controlParent is IChildIndexProvider childIndexProvider) @@ -78,7 +81,7 @@ namespace Avalonia.Styling if (reversed) { - if (childIndexProvider.TotalCount is int totalCountValue) + if (childIndexProvider.TryGetTotalCount(out var totalCountValue)) { index = totalCountValue - index; } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index 28960c8bf6..022ff0c3a4 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -1,10 +1,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Threading.Tasks; using System.Xml; using Avalonia.Controls; -using Avalonia.Markup.Data; using Avalonia.Markup.Xaml.Styling; using Avalonia.Markup.Xaml.Templates; using Avalonia.Media; diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs index 8a8e46fc4b..1d101b8ea0 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs @@ -1,7 +1,5 @@ using System.Threading.Tasks; - using Avalonia.Controls; - using Xunit; namespace Avalonia.Styling.UnitTests diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs index 8d9d490724..00a99523c7 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthLastChild.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; - using Avalonia.Controls; using Xunit; From d64a700b4fd2fae11ee3de45f83e18c4c3984562 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 21 Oct 2021 20:08:12 -0400 Subject: [PATCH 15/96] Imrpove nth-child parsing --- .../Markup/Parsers/SelectorGrammar.cs | 56 +++++++++++++-- .../Parsers/SelectorGrammarTests.cs | 69 +++++++++++++++++++ 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index 56e64329b7..953a7e9a15 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -350,12 +350,28 @@ namespace Avalonia.Markup.Parsers } else { - var stepOrOffsetSpan = r.TakeWhile(c => c != ')' && c != 'n'); - if (!int.TryParse(stepOrOffsetSpan.ToString().Trim(), out var stepOrOffset)) + r.SkipWhitespace(); + + var stepOrOffset = 0; + var stepOrOffsetStr = r.TakeWhile(c => char.IsDigit(c) || c == '-' || c == '+').ToString(); + if (stepOrOffsetStr.Length == 0 + || (stepOrOffsetStr.Length == 1 + && stepOrOffsetStr[0] == '+')) + { + stepOrOffset = 1; + } + else if (stepOrOffsetStr.Length == 1 + && stepOrOffsetStr[0] == '-') + { + stepOrOffset = -1; + } + else if (!int.TryParse(stepOrOffsetStr.ToString(), out stepOrOffset)) { throw new ExpressionParseException(r.Position, "Couldn't parse nth-child step or offset value. Integer was expected."); } + r.SkipWhitespace(); + if (r.Peek == ')') { step = 0; @@ -365,13 +381,41 @@ namespace Avalonia.Markup.Parsers { step = stepOrOffset; + if (r.Peek != 'n') + { + throw new ExpressionParseException(r.Position, "Couldn't parse nth-child step value, \"xn+y\" pattern was expected."); + } + r.Skip(1); // skip 'n' - var offsetSpan = r.TakeUntil(')').TrimStart(); - if (offsetSpan.Length != 0 - && !int.TryParse(offsetSpan.ToString().Trim(), out offset)) + r.SkipWhitespace(); + + if (r.Peek != ')') { - throw new ExpressionParseException(r.Position, "Couldn't parse nth-child offset value. Integer was expected."); + int sign; + var nextChar = r.Take(); + if (nextChar == '+') + { + sign = 1; + } + else if (nextChar == '-') + { + sign = -1; + } + else + { + throw new ExpressionParseException(r.Position, "Couldn't parse nth-child sign. '+' or '-' was expected."); + } + + r.SkipWhitespace(); + + if (sign != 0 + && !int.TryParse(r.TakeUntil(')').ToString(), out offset)) + { + throw new ExpressionParseException(r.Position, "Couldn't parse nth-child offset value. Integer was expected."); + } + + offset *= sign; } } } diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs index 543d44c492..568f6deaf2 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs @@ -236,6 +236,75 @@ namespace Avalonia.Markup.UnitTests.Parsers result); } + [Theory] + [InlineData(":nth-child(xn+2)")] + [InlineData(":nth-child(2n+b)")] + [InlineData(":nth-child(2n+)")] + [InlineData(":nth-child(2na)")] + [InlineData(":nth-child(2x+1)")] + public void NthChild_Invalid_Inputs(string input) + { + Assert.Throws(() => SelectorGrammar.Parse(input)); + } + + [Theory] + [InlineData(":nth-child(+1)", 0, 1)] + [InlineData(":nth-child(1)", 0, 1)] + [InlineData(":nth-child(-1)", 0, -1)] + [InlineData(":nth-child(2n+1)", 2, 1)] + [InlineData(":nth-child(n)", 1, 0)] + [InlineData(":nth-child(+n)", 1, 0)] + [InlineData(":nth-child(-n)", -1, 0)] + [InlineData(":nth-child(-2n)", -2, 0)] + [InlineData(":nth-child(n+5)", 1, 5)] + [InlineData(":nth-child(n-5)", 1, -5)] + [InlineData(":nth-child( 2n + 1 )", 2, 1)] + [InlineData(":nth-child( 2n - 1 )", 2, -1)] + public void NthChild_Variations(string input, int step, int offset) + { + var result = SelectorGrammar.Parse(input); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.NthChildSyntax() + { + Step = step, + Offset = offset + } + }, + result); + } + + [Theory] + [InlineData(":nth-last-child(+1)", 0, 1)] + [InlineData(":nth-last-child(1)", 0, 1)] + [InlineData(":nth-last-child(-1)", 0, -1)] + [InlineData(":nth-last-child(2n+1)", 2, 1)] + [InlineData(":nth-last-child(n)", 1, 0)] + [InlineData(":nth-last-child(+n)", 1, 0)] + [InlineData(":nth-last-child(-n)", -1, 0)] + [InlineData(":nth-last-child(-2n)", -2, 0)] + [InlineData(":nth-last-child(n+5)", 1, 5)] + [InlineData(":nth-last-child(n-5)", 1, -5)] + [InlineData(":nth-last-child( 2n + 1 )", 2, 1)] + [InlineData(":nth-last-child( 2n - 1 )", 2, -1)] + public void NthLastChild_Variations(string input, int step, int offset) + { + var result = SelectorGrammar.Parse(input); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.NthLastChildSyntax() + { + Step = step, + Offset = offset + } + }, + result); + } + [Fact] public void OfType_NthChild() { From bab044980569a7f80ead99792d48da4969a2fd0f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 21 Oct 2021 20:46:24 -0400 Subject: [PATCH 16/96] Added tests from nthmaster.com --- .../SelectorTests_NthChild.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs index 1d101b8ea0..e1507be110 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_NthChild.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; using Xunit; @@ -205,6 +206,76 @@ namespace Avalonia.Styling.UnitTests Assert.Equal(SelectorMatch.NeverThisInstance, target.Match(b1)); } + + [Theory] // http://nthmaster.com/ + [InlineData(+0, 8, false, false, false, false, false, false, false, true , false, false, false)] + [InlineData(+1, 6, false, false, false, false, false, true , true , true , true , true , true )] + [InlineData(-1, 9, true , true , true , true , true , true , true , true , true , false, false)] + public async Task Nth_Child_Master_Com_Test_Sigle_Selector( + int step, int offset, params bool[] items) + { + var panel = new StackPanel(); + panel.Children.AddRange(items.Select(_ => new Border())); + + var previous = default(Selector).OfType(); + var target = previous.NthChild(step, offset); + + var results = new bool[items.Length]; + for (int index = 0; index < items.Length; index++) + { + var border = panel.Children[index]; + results[index] = await target.Match(border).Activator!.Take(1); + } + + Assert.Equal(items, results); + } + + [Theory] // http://nthmaster.com/ + [InlineData(+1, 4, -1, 8, false, false, false, true , true , true , true , true , false, false, false)] + [InlineData(+3, 1, +2, 0, false, false, false, true , false, false, false, false, false, true , false)] + public async Task Nth_Child_Master_Com_Test_Double_Selector( + int step1, int offset1, int step2, int offset2, params bool[] items) + { + var panel = new StackPanel(); + panel.Children.AddRange(items.Select(_ => new Border())); + + var previous = default(Selector).OfType(); + var middle = previous.NthChild(step1, offset1); + var target = middle.NthChild(step2, offset2); + + var results = new bool[items.Length]; + for (int index = 0; index < items.Length; index++) + { + var border = panel.Children[index]; + results[index] = await target.Match(border).Activator!.Take(1); + } + + Assert.Equal(items, results); + } + + [Theory] // http://nthmaster.com/ + [InlineData(+1, 2, 2, 1, -1, 9, false, false, true , false, true , false, true , false, true , false, false)] + public async Task Nth_Child_Master_Com_Test_Triple_Selector( + int step1, int offset1, int step2, int offset2, int step3, int offset3, params bool[] items) + { + var panel = new StackPanel(); + panel.Children.AddRange(items.Select(_ => new Border())); + + var previous = default(Selector).OfType(); + var middle1 = previous.NthChild(step1, offset1); + var middle2 = middle1.NthChild(step2, offset2); + var target = middle2.NthChild(step3, offset3); + + var results = new bool[items.Length]; + for (int index = 0; index < items.Length; index++) + { + var border = panel.Children[index]; + results[index] = await target.Match(border).Activator!.Take(1); + } + + Assert.Equal(items, results); + } + [Fact] public void Returns_Correct_TargetType() { From 141e749226c95557f4b44347525abbe3fa2db230 Mon Sep 17 00:00:00 2001 From: Jumar Macato <16554748+jmacato@users.noreply.github.com> Date: Fri, 22 Oct 2021 12:04:09 +0800 Subject: [PATCH 17/96] Initial Commit for handling DBus SNI Tray Icons gracefully and also making a skeleton class for the future XEmbed Tray Icon impl. --- .../DbusSNITrayIconImpl.cs | 358 +++++++++++++++++ src/Avalonia.X11/X11TrayIconImpl.cs | 379 ++++-------------- src/Avalonia.X11/XEmbedTrayIconImpl.cs | 36 ++ 3 files changed, 462 insertions(+), 311 deletions(-) create mode 100644 src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs create mode 100644 src/Avalonia.X11/XEmbedTrayIconImpl.cs diff --git a/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs b/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs new file mode 100644 index 0000000000..1fb74f132a --- /dev/null +++ b/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs @@ -0,0 +1,358 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Avalonia.Controls.Platform; +using Avalonia.FreeDesktop; +using Avalonia.Logging; +using Avalonia.Platform; +using Tmds.DBus; + +[assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] + +namespace Avalonia.FreeDesktop +{ + public class DbusSNITrayIconImpl + { + private static int s_trayIconInstanceId; + private readonly ObjectPath _dbusMenuPath; + private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; + private readonly Connection? _connection; + private DbusPixmap _icon; + + private IStatusNotifierWatcher? _statusNotifierWatcher; + + private string? _sysTrayServiceName; + private string? _tooltipText; + private bool _isActive; + private bool _isDisposed; + private readonly bool _ctorFinished; + + public INativeMenuExporter? MenuExporter { get; } + public Action? OnClicked { get; set; } + + public bool IsActive => _isActive; + + public DbusSNITrayIconImpl(Connection connection) + { + _connection = connection; + _dbusMenuPath = DBusMenuExporter.GenerateDBusMenuObjPath; + MenuExporter = DBusMenuExporter.TryCreateDetachedNativeMenu(_dbusMenuPath, _connection); + CreateTrayIcon(); + _ctorFinished = true; + } + + public async void CreateTrayIcon() + { + if (_connection is null) + return; + + try + { + _statusNotifierWatcher = _connection.CreateProxy( + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher"); + } + catch + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, + "DBUS: org.kde.StatusNotifierWatcher service is not available on this system. System Tray Icons will not work without it."); + } + + if (_statusNotifierWatcher is null) + return; + + var pid = Process.GetCurrentProcess().Id; + var tid = s_trayIconInstanceId++; + + _sysTrayServiceName = $"org.kde.StatusNotifierItem-{pid}-{tid}"; + _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_dbusMenuPath); + + await _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); + + await _connection.RegisterServiceAsync(_sysTrayServiceName); + + await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + + _statusNotifierItemDbusObj.SetTitleAndTooltip(_tooltipText); + _statusNotifierItemDbusObj.SetIcon(_icon); + + _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; + + _isActive = true; + } + + public async void DestroyTrayIcon() + { + if (_connection is null) + return; + _connection.UnregisterObject(_statusNotifierItemDbusObj); + await _connection.UnregisterServiceAsync(_sysTrayServiceName); + _isActive = false; + } + + public void Dispose() + { + _isDisposed = true; + DestroyTrayIcon(); + _connection?.Dispose(); + } + + public void SetIcon(UIntPtr[] x11iconData) + { + if (_isDisposed) + return; + var w = (int)x11iconData[0]; + var h = (int)x11iconData[1]; + + var pixLength = w * h; + var pixByteArrayCounter = 0; + var pixByteArray = new byte[w * h * 4]; + + for (var i = 0; i < pixLength; i++) + { + var rawPixel = x11iconData[i + 2].ToUInt32(); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF000000) >> 24); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF0000) >> 16); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF00) >> 8); + pixByteArray[pixByteArrayCounter++] = (byte)(rawPixel & 0xFF); + } + + _icon = new DbusPixmap(w, h, pixByteArray); + _statusNotifierItemDbusObj?.SetIcon(_icon); + } + + public void SetIsVisible(bool visible) + { + if (_isDisposed || !_ctorFinished) + return; + + if (visible & !_isActive) + { + DestroyTrayIcon(); + CreateTrayIcon(); + } + else if (!visible & _isActive) + { + DestroyTrayIcon(); + } + } + + public void SetToolTipText(string? text) + { + if (_isDisposed || text is null) + return; + _tooltipText = text; + _statusNotifierItemDbusObj?.SetTitleAndTooltip(_tooltipText); + } + } + + /// + /// DBus Object used for setting system tray icons. + /// + /// + /// Useful guide: https://web.archive.org/web/20210818173850/https://www.notmart.org/misc/statusnotifieritem/statusnotifieritem.html + /// + internal class StatusNotifierItemDbusObj : IStatusNotifierItem + { + private readonly StatusNotifierItemProperties _backingProperties; + public event Action? OnTitleChanged; + public event Action? OnIconChanged; + public event Action? OnAttentionIconChanged; + public event Action? OnOverlayIconChanged; + public event Action? OnTooltipChanged; + public Action? NewStatusAsync { get; set; } + public Action? ActivationDelegate { get; set; } + public ObjectPath ObjectPath { get; } + + public StatusNotifierItemDbusObj(ObjectPath dbusmenuPath) + { + ObjectPath = new ObjectPath($"/StatusNotifierItem"); + + _backingProperties = new StatusNotifierItemProperties + { + Menu = dbusmenuPath, // Needs a dbus menu somehow + ToolTip = new ToolTip("") + }; + + InvalidateAll(); + } + + public Task ContextMenuAsync(int x, int y) => Task.CompletedTask; + + public Task ActivateAsync(int x, int y) + { + ActivationDelegate?.Invoke(); + return Task.CompletedTask; + } + + public Task SecondaryActivateAsync(int x, int y) => Task.CompletedTask; + + public Task ScrollAsync(int delta, string orientation) => Task.CompletedTask; + + public void InvalidateAll() + { + OnTitleChanged?.Invoke(); + OnIconChanged?.Invoke(); + OnOverlayIconChanged?.Invoke(); + OnAttentionIconChanged?.Invoke(); + OnTooltipChanged?.Invoke(); + } + + public Task WatchNewTitleAsync(Action handler, Action onError) + { + OnTitleChanged += handler; + return Task.FromResult(Disposable.Create(() => OnTitleChanged -= handler)); + } + + public Task WatchNewIconAsync(Action handler, Action onError) + { + OnIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnIconChanged -= handler)); + } + + public Task WatchNewAttentionIconAsync(Action handler, Action onError) + { + OnAttentionIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnAttentionIconChanged -= handler)); + } + + public Task WatchNewOverlayIconAsync(Action handler, Action onError) + { + OnOverlayIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnOverlayIconChanged -= handler)); + } + + public Task WatchNewToolTipAsync(Action handler, Action onError) + { + OnTooltipChanged += handler; + return Task.FromResult(Disposable.Create(() => OnTooltipChanged -= handler)); + } + + public Task WatchNewStatusAsync(Action handler, Action onError) + { + NewStatusAsync += handler; + return Task.FromResult(Disposable.Create(() => NewStatusAsync -= handler)); + } + + public Task GetAsync(string prop) => Task.FromResult(new object()); + + public Task GetAllAsync() => Task.FromResult(_backingProperties); + + public Task SetAsync(string prop, object val) => Task.CompletedTask; + + public Task WatchPropertiesAsync(Action handler) => + Task.FromResult(Disposable.Empty); + + public void SetIcon(DbusPixmap dbusPixmap) + { + _backingProperties.IconPixmap = new[] { dbusPixmap }; + InvalidateAll(); + } + + public void SetTitleAndTooltip(string? text) + { + if (text is null) + return; + + _backingProperties.Id = text; + _backingProperties.Category = "ApplicationStatus"; + _backingProperties.Status = text; + _backingProperties.Title = text; + _backingProperties.ToolTip = new ToolTip(text); + + InvalidateAll(); + } + } + + [DBusInterface("org.kde.StatusNotifierWatcher")] + internal interface IStatusNotifierWatcher : IDBusObject + { + Task RegisterStatusNotifierItemAsync(string Service); + Task RegisterStatusNotifierHostAsync(string Service); + } + + [DBusInterface("org.kde.StatusNotifierItem")] + internal interface IStatusNotifierItem : IDBusObject + { + Task ContextMenuAsync(int x, int y); + Task ActivateAsync(int x, int y); + Task SecondaryActivateAsync(int x, int y); + Task ScrollAsync(int delta, string orientation); + Task WatchNewTitleAsync(Action handler, Action onError); + Task WatchNewIconAsync(Action handler, Action onError); + Task WatchNewAttentionIconAsync(Action handler, Action onError); + Task WatchNewOverlayIconAsync(Action handler, Action onError); + Task WatchNewToolTipAsync(Action handler, Action onError); + Task WatchNewStatusAsync(Action handler, Action onError); + Task GetAsync(string prop); + Task GetAllAsync(); + Task SetAsync(string prop, object val); + Task WatchPropertiesAsync(Action handler); + } + + [Dictionary] + // This class is used by Tmds.Dbus to ferry properties + // from the SNI spec. + // Don't change this to actual C# properties since + // Tmds.Dbus will get confused. + internal class StatusNotifierItemProperties + { + public string? Category; + + public string? Id; + + public string? Title; + + public string? Status; + + public ObjectPath Menu; + + public DbusPixmap[]? IconPixmap; + + public ToolTip ToolTip; + } + + internal struct ToolTip + { + public readonly string First; + public readonly DbusPixmap[] Second; + public readonly string Third; + public readonly string Fourth; + + private static readonly DbusPixmap[] s_blank = + { + new DbusPixmap(0, 0, Array.Empty()), new DbusPixmap(0, 0, Array.Empty()) + }; + + public ToolTip(string message) : this("", s_blank, message, "") + { + } + + public ToolTip(string first, DbusPixmap[] second, string third, string fourth) + { + First = first; + Second = second; + Third = third; + Fourth = fourth; + } + } + + internal readonly struct DbusPixmap + { + public readonly int Width; + public readonly int Height; + public readonly byte[] Data; + + public DbusPixmap(int width, int height, byte[] data) + { + Width = width; + Height = height; + Data = data; + } + } +} diff --git a/src/Avalonia.X11/X11TrayIconImpl.cs b/src/Avalonia.X11/X11TrayIconImpl.cs index 371ff75408..ca8ed8ec35 100644 --- a/src/Avalonia.X11/X11TrayIconImpl.cs +++ b/src/Avalonia.X11/X11TrayIconImpl.cs @@ -1,367 +1,124 @@ -#nullable enable - using System; -using System.Diagnostics; -using System.Reactive.Disposables; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; using Avalonia.Controls.Platform; using Avalonia.FreeDesktop; using Avalonia.Logging; using Avalonia.Platform; -using Tmds.DBus; - -[assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] namespace Avalonia.X11 { internal class X11TrayIconImpl : ITrayIconImpl { - private static int s_trayIconInstanceId; - private readonly ObjectPath _dbusMenuPath; - private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; - private readonly Connection? _connection; - private DbusPixmap _icon; - - private IStatusNotifierWatcher? _statusNotifierWatcher; - - private string? _sysTrayServiceName; - private string? _tooltipText; - private bool _isActive; - private bool _isDisposed; - private readonly bool _ctorFinished; - - public INativeMenuExporter? MenuExporter { get; } - public Action? OnClicked { get; set; } - public X11TrayIconImpl() { - _connection = DBusHelper.TryGetConnection(); + _xEmbedTrayIcon = new XEmbedTrayIconImpl(); + + var _connection = DBusHelper.TryGetConnection(); if (_connection is null) { Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) ?.Log(this, "Unable to get a dbus connection for system tray icons."); - return; } - _dbusMenuPath = DBusMenuExporter.GenerateDBusMenuObjPath; - MenuExporter = DBusMenuExporter.TryCreateDetachedNativeMenu(_dbusMenuPath, _connection); - CreateTrayIcon(); - _ctorFinished = true; + _dbusSniTrayIcon = new DbusSNITrayIconImpl(_connection); } - public async void CreateTrayIcon() - { - if (_connection is null) - return; - - try - { - _statusNotifierWatcher = _connection.CreateProxy( - "org.kde.StatusNotifierWatcher", - "/StatusNotifierWatcher"); - } - catch - { - Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) - ?.Log(this, - "DBUS: org.kde.StatusNotifierWatcher service is not available on this system. System Tray Icons will not work without it."); - } - - if (_statusNotifierWatcher is null) - return; - - var pid = Process.GetCurrentProcess().Id; - var tid = s_trayIconInstanceId++; - - _sysTrayServiceName = $"org.kde.StatusNotifierItem-{pid}-{tid}"; - _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_dbusMenuPath); - - await _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); - - await _connection.RegisterServiceAsync(_sysTrayServiceName); - - await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); - - _statusNotifierItemDbusObj.SetTitleAndTooltip(_tooltipText); - _statusNotifierItemDbusObj.SetIcon(_icon); + private readonly DbusSNITrayIconImpl _dbusSniTrayIcon; - _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; - - _isActive = true; - } - - public async void DestroyTrayIcon() - { - if (_connection is null) - return; - _connection.UnregisterObject(_statusNotifierItemDbusObj); - await _connection.UnregisterServiceAsync(_sysTrayServiceName); - _isActive = false; - } + private readonly XEmbedTrayIconImpl _xEmbedTrayIcon; + private bool _isDisposed; public void Dispose() { + _dbusSniTrayIcon?.Dispose(); + _xEmbedTrayIcon?.Dispose(); _isDisposed = true; - DestroyTrayIcon(); - _connection?.Dispose(); } public void SetIcon(IWindowIconImpl? icon) { - if (_isDisposed) - return; - if (!(icon is X11IconData x11icon)) - return; + if (_isDisposed) return; - var w = (int)x11icon.Data[0]; - var h = (int)x11icon.Data[1]; - - var pixLength = w * h; - var pixByteArrayCounter = 0; - var pixByteArray = new byte[w * h * 4]; + if (_dbusSniTrayIcon?.IsActive ?? false) + { + if (!(icon is X11IconData x11icon)) + return; - for (var i = 0; i < pixLength; i++) + _dbusSniTrayIcon.SetIcon(x11icon.Data); + } + else { - var rawPixel = x11icon.Data[i + 2].ToUInt32(); - pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF000000) >> 24); - pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF0000) >> 16); - pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF00) >> 8); - pixByteArray[pixByteArrayCounter++] = (byte)(rawPixel & 0xFF); + _xEmbedTrayIcon.SetIcon(icon); } - - _icon = new DbusPixmap(w, h, pixByteArray); - _statusNotifierItemDbusObj?.SetIcon(_icon); } - public void SetIsVisible(bool visible) + public void SetToolTipText(string? text) { - if (_isDisposed || !_ctorFinished) - return; + if (_isDisposed) return; - if (visible & !_isActive) + if (_dbusSniTrayIcon?.IsActive ?? false) { - DestroyTrayIcon(); - CreateTrayIcon(); + _dbusSniTrayIcon.SetToolTipText(text); } - else if (!visible & _isActive) + else { - DestroyTrayIcon(); + _xEmbedTrayIcon.SetToolTipText(text); } } - public void SetToolTipText(string? text) - { - if (_isDisposed || text is null) - return; - _tooltipText = text; - _statusNotifierItemDbusObj?.SetTitleAndTooltip(_tooltipText); - } - } - - /// - /// DBus Object used for setting system tray icons. - /// - /// - /// Useful guide: https://web.archive.org/web/20210818173850/https://www.notmart.org/misc/statusnotifieritem/statusnotifieritem.html - /// - internal class StatusNotifierItemDbusObj : IStatusNotifierItem - { - private readonly StatusNotifierItemProperties _backingProperties; - public event Action? OnTitleChanged; - public event Action? OnIconChanged; - public event Action? OnAttentionIconChanged; - public event Action? OnOverlayIconChanged; - public event Action? OnTooltipChanged; - public Action? NewStatusAsync { get; set; } - public Action? ActivationDelegate { get; set; } - public ObjectPath ObjectPath { get; } - - public StatusNotifierItemDbusObj(ObjectPath dbusmenuPath) + public void SetIsVisible(bool visible) { - ObjectPath = new ObjectPath($"/StatusNotifierItem"); + if (_isDisposed) return; - _backingProperties = new StatusNotifierItemProperties + if (_dbusSniTrayIcon?.IsActive ?? false) { - Menu = dbusmenuPath, // Needs a dbus menu somehow - ToolTip = new ToolTip("") - }; - - InvalidateAll(); - } - - public Task ContextMenuAsync(int x, int y) => Task.CompletedTask; - - public Task ActivateAsync(int x, int y) - { - ActivationDelegate?.Invoke(); - return Task.CompletedTask; - } - - public Task SecondaryActivateAsync(int x, int y) => Task.CompletedTask; - - public Task ScrollAsync(int delta, string orientation) => Task.CompletedTask; - - public void InvalidateAll() - { - OnTitleChanged?.Invoke(); - OnIconChanged?.Invoke(); - OnOverlayIconChanged?.Invoke(); - OnAttentionIconChanged?.Invoke(); - OnTooltipChanged?.Invoke(); - } - - public Task WatchNewTitleAsync(Action handler, Action onError) - { - OnTitleChanged += handler; - return Task.FromResult(Disposable.Create(() => OnTitleChanged -= handler)); - } - - public Task WatchNewIconAsync(Action handler, Action onError) - { - OnIconChanged += handler; - return Task.FromResult(Disposable.Create(() => OnIconChanged -= handler)); - } - - public Task WatchNewAttentionIconAsync(Action handler, Action onError) - { - OnAttentionIconChanged += handler; - return Task.FromResult(Disposable.Create(() => OnAttentionIconChanged -= handler)); - } - - public Task WatchNewOverlayIconAsync(Action handler, Action onError) - { - OnOverlayIconChanged += handler; - return Task.FromResult(Disposable.Create(() => OnOverlayIconChanged -= handler)); - } - - public Task WatchNewToolTipAsync(Action handler, Action onError) - { - OnTooltipChanged += handler; - return Task.FromResult(Disposable.Create(() => OnTooltipChanged -= handler)); - } - - public Task WatchNewStatusAsync(Action handler, Action onError) - { - NewStatusAsync += handler; - return Task.FromResult(Disposable.Create(() => NewStatusAsync -= handler)); - } - - public Task GetAsync(string prop) => Task.FromResult(new object()); - - public Task GetAllAsync() => Task.FromResult(_backingProperties); - - public Task SetAsync(string prop, object val) => Task.CompletedTask; - - public Task WatchPropertiesAsync(Action handler) => - Task.FromResult(Disposable.Empty); - - public void SetIcon(DbusPixmap dbusPixmap) - { - _backingProperties.IconPixmap = new[] { dbusPixmap }; - InvalidateAll(); - } - - public void SetTitleAndTooltip(string? text) - { - if (text is null) - return; - - _backingProperties.Id = text; - _backingProperties.Category = "ApplicationStatus"; - _backingProperties.Status = text; - _backingProperties.Title = text; - _backingProperties.ToolTip = new ToolTip(text); - - InvalidateAll(); - } - } - - [DBusInterface("org.kde.StatusNotifierWatcher")] - internal interface IStatusNotifierWatcher : IDBusObject - { - Task RegisterStatusNotifierItemAsync(string Service); - Task RegisterStatusNotifierHostAsync(string Service); - } - - [DBusInterface("org.kde.StatusNotifierItem")] - internal interface IStatusNotifierItem : IDBusObject - { - Task ContextMenuAsync(int x, int y); - Task ActivateAsync(int x, int y); - Task SecondaryActivateAsync(int x, int y); - Task ScrollAsync(int delta, string orientation); - Task WatchNewTitleAsync(Action handler, Action onError); - Task WatchNewIconAsync(Action handler, Action onError); - Task WatchNewAttentionIconAsync(Action handler, Action onError); - Task WatchNewOverlayIconAsync(Action handler, Action onError); - Task WatchNewToolTipAsync(Action handler, Action onError); - Task WatchNewStatusAsync(Action handler, Action onError); - Task GetAsync(string prop); - Task GetAllAsync(); - Task SetAsync(string prop, object val); - Task WatchPropertiesAsync(Action handler); - } - - [Dictionary] - // This class is used by Tmds.Dbus to ferry properties - // from the SNI spec. - // Don't change this to actual C# properties since - // Tmds.Dbus will get confused. - internal class StatusNotifierItemProperties - { - public string? Category; - - public string? Id; - - public string? Title; - - public string? Status; - - public ObjectPath Menu; - - public DbusPixmap[]? IconPixmap; - - public ToolTip ToolTip; - } - - internal struct ToolTip - { - public readonly string First; - public readonly DbusPixmap[] Second; - public readonly string Third; - public readonly string Fourth; - - private static readonly DbusPixmap[] s_blank = - { - new DbusPixmap(0, 0, Array.Empty()), new DbusPixmap(0, 0, Array.Empty()) - }; - - public ToolTip(string message) : this("", s_blank, message, "") - { + _dbusSniTrayIcon.SetIsVisible(visible); + } + else + { + _xEmbedTrayIcon.SetIsVisible(visible); + } } - public ToolTip(string first, DbusPixmap[] second, string third, string fourth) + public INativeMenuExporter? MenuExporter { - First = first; - Second = second; - Third = third; - Fourth = fourth; + get + { + if (_dbusSniTrayIcon?.IsActive ?? false) + { + return _dbusSniTrayIcon.MenuExporter; + } + else + { + return _xEmbedTrayIcon.MenuExporter; + } + } } - } - - internal readonly struct DbusPixmap - { - public readonly int Width; - public readonly int Height; - public readonly byte[] Data; - public DbusPixmap(int width, int height, byte[] data) + public Action? OnClicked { - Width = width; - Height = height; - Data = data; + get + { + if (_dbusSniTrayIcon?.IsActive ?? false) + { + return _dbusSniTrayIcon.OnClicked; + } + else + { + return _xEmbedTrayIcon.OnClicked; + } + } + set + { + if (_dbusSniTrayIcon?.IsActive ?? false) + { + _dbusSniTrayIcon.OnClicked = value; + } + else + { + _xEmbedTrayIcon.OnClicked = value; + } + } } } } diff --git a/src/Avalonia.X11/XEmbedTrayIconImpl.cs b/src/Avalonia.X11/XEmbedTrayIconImpl.cs new file mode 100644 index 0000000000..4b5f0d0a57 --- /dev/null +++ b/src/Avalonia.X11/XEmbedTrayIconImpl.cs @@ -0,0 +1,36 @@ +using System; +using Avalonia.Controls.Platform; +using Avalonia.Logging; +using Avalonia.Platform; + +namespace Avalonia.X11 +{ + internal class XEmbedTrayIconImpl + { + public XEmbedTrayIconImpl() + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, + "TODO: XEmbed System Tray Icons is not implemented yet. Tray icons won't be available on this system."); + } + + public void Dispose() + { + } + + public void SetIcon(IWindowIconImpl? icon) + { + } + + public void SetToolTipText(string? text) + { + } + + public void SetIsVisible(bool visible) + { + } + + public INativeMenuExporter? MenuExporter { get; } + public Action? OnClicked { get; set; } + } +} From c4b0b99027491c78988a81559244eddc99151e4c Mon Sep 17 00:00:00 2001 From: Jumar Macato <16554748+jmacato@users.noreply.github.com> Date: Fri, 22 Oct 2021 15:05:35 +0800 Subject: [PATCH 18/96] Gracefully handle tray service restarts --- .../DbusSNITrayIconImpl.cs | 129 +++++++++++++----- src/Avalonia.X11/X11TrayIconImpl.cs | 11 +- src/Avalonia.X11/XEmbedTrayIconImpl.cs | 16 ++- 3 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs b/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs index 1fb74f132a..6ca05efe50 100644 --- a/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs +++ b/src/Avalonia.FreeDesktop/DbusSNITrayIconImpl.cs @@ -6,49 +6,55 @@ using System.Reactive.Disposables; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Avalonia.Controls.Platform; -using Avalonia.FreeDesktop; using Avalonia.Logging; -using Avalonia.Platform; using Tmds.DBus; [assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] namespace Avalonia.FreeDesktop { - public class DbusSNITrayIconImpl + public class DbusSNITrayIconImpl { - private static int s_trayIconInstanceId; + private static int s_trayIconInstanceId = 0; private readonly ObjectPath _dbusMenuPath; private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; private readonly Connection? _connection; private DbusPixmap _icon; - private IStatusNotifierWatcher? _statusNotifierWatcher; - private string? _sysTrayServiceName; private string? _tooltipText; - private bool _isActive; private bool _isDisposed; - private readonly bool _ctorFinished; + private bool _serviceConnected; + private readonly IDisposable _serviceWatchDisposable; + private bool _isVisible; public INativeMenuExporter? MenuExporter { get; } public Action? OnClicked { get; set; } - public bool IsActive => _isActive; - - public DbusSNITrayIconImpl(Connection connection) + public bool IsActive => _serviceConnected; + + public DbusSNITrayIconImpl() { - _connection = connection; + _connection = DBusHelper.TryGetConnection(); + + if (_connection is null) + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, "Unable to get a dbus connection for system tray icons."); + + return; + } + _dbusMenuPath = DBusMenuExporter.GenerateDBusMenuObjPath; MenuExporter = DBusMenuExporter.TryCreateDetachedNativeMenu(_dbusMenuPath, _connection); + InitializeSNWService(); CreateTrayIcon(); - _ctorFinished = true; + _serviceWatchDisposable = Watch(); } - public async void CreateTrayIcon() + private void InitializeSNWService() { - if (_connection is null) - return; + if (_connection is null || _isDisposed) return; try { @@ -61,38 +67,83 @@ namespace Avalonia.FreeDesktop Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) ?.Log(this, "DBUS: org.kde.StatusNotifierWatcher service is not available on this system. System Tray Icons will not work without it."); + + return; } - if (_statusNotifierWatcher is null) + _serviceConnected = true; + } + + + private async Task Watch() => + await _connection?.ResolveServiceOwnerAsync("org.kde.StatusNotifierWatcher", OnNameChange)!; + + + private void OnNameChange(ServiceOwnerChangedEventArgs obj) + { + if (_isDisposed) return; + if (!_serviceConnected & obj.NewOwner != null) + { + _serviceConnected = true; + + if (_isVisible) + { + DestroyTrayIcon(); + CreateTrayIcon(); + } + else + { + DestroyTrayIcon(); + } + } + else if (_serviceConnected & obj.NewOwner is null) + { + s_trayIconInstanceId = 0; + _serviceConnected = false; + } + } + + public void CreateTrayIcon() + { + if (_connection is null || !_serviceConnected || _isDisposed) + return; + + var pid = Process.GetCurrentProcess().Id; var tid = s_trayIconInstanceId++; _sysTrayServiceName = $"org.kde.StatusNotifierItem-{pid}-{tid}"; _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_dbusMenuPath); - await _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); - - await _connection.RegisterServiceAsync(_sysTrayServiceName); - await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + try + { + _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); + _connection.RegisterServiceAsync(_sysTrayServiceName); + _statusNotifierWatcher?.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + } + catch (Exception e) + { + _serviceConnected = false; + } _statusNotifierItemDbusObj.SetTitleAndTooltip(_tooltipText); _statusNotifierItemDbusObj.SetIcon(_icon); _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; - - _isActive = true; + _isVisible = true; } - public async void DestroyTrayIcon() + public void DestroyTrayIcon() { - if (_connection is null) + if (_connection is null || !_serviceConnected || _isDisposed) return; + _connection.UnregisterObject(_statusNotifierItemDbusObj); - await _connection.UnregisterServiceAsync(_sysTrayServiceName); - _isActive = false; + _connection.UnregisterServiceAsync(_sysTrayServiceName); + _isVisible = false; } public void Dispose() @@ -100,12 +151,13 @@ namespace Avalonia.FreeDesktop _isDisposed = true; DestroyTrayIcon(); _connection?.Dispose(); + _serviceWatchDisposable?.Dispose(); } public void SetIcon(UIntPtr[] x11iconData) { if (_isDisposed) - return; + return; var w = (int)x11iconData[0]; var h = (int)x11iconData[1]; @@ -128,15 +180,15 @@ namespace Avalonia.FreeDesktop public void SetIsVisible(bool visible) { - if (_isDisposed || !_ctorFinished) + if (_isDisposed) return; - if (visible & !_isActive) + if (visible && !_isVisible) { DestroyTrayIcon(); CreateTrayIcon(); } - else if (!visible & _isActive) + else if (!visible && _isVisible) { DestroyTrayIcon(); } @@ -239,7 +291,20 @@ namespace Avalonia.FreeDesktop return Task.FromResult(Disposable.Create(() => NewStatusAsync -= handler)); } - public Task GetAsync(string prop) => Task.FromResult(new object()); + public async Task GetAsync(string prop) + { + return prop switch + { + nameof(_backingProperties.Category) => _backingProperties.Category, + nameof(_backingProperties.Id) => _backingProperties.Id, + nameof(_backingProperties.Menu) => _backingProperties.Menu, + nameof(_backingProperties.IconPixmap) => _backingProperties.IconPixmap, + nameof(_backingProperties.Status) => _backingProperties.Status, + nameof(_backingProperties.Title) => _backingProperties.Title, + nameof(_backingProperties.ToolTip) => _backingProperties.ToolTip, + _ => null + }; + } public Task GetAllAsync() => Task.FromResult(_backingProperties); diff --git a/src/Avalonia.X11/X11TrayIconImpl.cs b/src/Avalonia.X11/X11TrayIconImpl.cs index ca8ed8ec35..9e03dcd604 100644 --- a/src/Avalonia.X11/X11TrayIconImpl.cs +++ b/src/Avalonia.X11/X11TrayIconImpl.cs @@ -11,16 +11,7 @@ namespace Avalonia.X11 public X11TrayIconImpl() { _xEmbedTrayIcon = new XEmbedTrayIconImpl(); - - var _connection = DBusHelper.TryGetConnection(); - - if (_connection is null) - { - Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) - ?.Log(this, "Unable to get a dbus connection for system tray icons."); - } - - _dbusSniTrayIcon = new DbusSNITrayIconImpl(_connection); + _dbusSniTrayIcon = new DbusSNITrayIconImpl(); } private readonly DbusSNITrayIconImpl _dbusSniTrayIcon; diff --git a/src/Avalonia.X11/XEmbedTrayIconImpl.cs b/src/Avalonia.X11/XEmbedTrayIconImpl.cs index 4b5f0d0a57..c2247565be 100644 --- a/src/Avalonia.X11/XEmbedTrayIconImpl.cs +++ b/src/Avalonia.X11/XEmbedTrayIconImpl.cs @@ -9,25 +9,39 @@ namespace Avalonia.X11 { public XEmbedTrayIconImpl() { + } + + private bool IsCalled; + + private void NotImplemented() + { + if(IsCalled) return; + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) ?.Log(this, "TODO: XEmbed System Tray Icons is not implemented yet. Tray icons won't be available on this system."); - } + IsCalled = true; + } + public void Dispose() { + NotImplemented(); } public void SetIcon(IWindowIconImpl? icon) { + NotImplemented(); } public void SetToolTipText(string? text) { + NotImplemented(); } public void SetIsVisible(bool visible) { + NotImplemented(); } public INativeMenuExporter? MenuExporter { get; } From ebd1f5366739447e2d11ff94ecb840d81040c865 Mon Sep 17 00:00:00 2001 From: Lubomir Tetak Date: Fri, 22 Oct 2021 12:42:29 +0200 Subject: [PATCH 19/96] DataGrid minimum distance threshold when dragging headers --- .../DataGridColumnHeader.cs | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs index 85fd55800a..915b36687c 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs @@ -35,6 +35,7 @@ namespace Avalonia.Controls private const int DATAGRIDCOLUMNHEADER_resizeRegionWidth = 5; private const double DATAGRIDCOLUMNHEADER_separatorThickness = 1; + private const int DATAGRIDCOLUMNHEADER_columnsDragTreshold = 5; private bool _areHandlersSuspended; private static DragMode _dragMode; @@ -448,19 +449,6 @@ namespace Avalonia.Controls OnMouseMove_Reorder(ref handled, mousePosition, mousePositionHeaders, distanceFromLeft, distanceFromRight); - // if we still haven't done anything about moving the mouse while - // the button is down, we remember that we're dragging, but we don't - // claim to have actually handled the event - if (_dragMode == DragMode.MouseDown) - { - _dragMode = DragMode.Drag; - } - - _lastMousePositionHeaders = mousePositionHeaders; - - if (args.Pointer.Captured != this && _dragMode == DragMode.Drag) - args.Pointer.Capture(this); - SetDragCursor(mousePosition); } @@ -732,15 +720,19 @@ namespace Avalonia.Controls { return; } - + //handle entry into reorder mode - if (_dragMode == DragMode.MouseDown && _dragColumn == null && (distanceFromRight > DATAGRIDCOLUMNHEADER_resizeRegionWidth && distanceFromLeft > DATAGRIDCOLUMNHEADER_resizeRegionWidth)) + if (_dragMode == DragMode.MouseDown && _dragColumn == null && _lastMousePositionHeaders != null && (distanceFromRight > DATAGRIDCOLUMNHEADER_resizeRegionWidth && distanceFromLeft > DATAGRIDCOLUMNHEADER_resizeRegionWidth)) { - handled = CanReorderColumn(OwningColumn); - - if (handled) + var distanceFromInitial = (Vector)(mousePositionHeaders - _lastMousePositionHeaders); + if (distanceFromInitial.Length > DATAGRIDCOLUMNHEADER_columnsDragTreshold) { - OnMouseMove_BeginReorder(mousePosition); + handled = CanReorderColumn(OwningColumn); + + if (handled) + { + OnMouseMove_BeginReorder(mousePosition); + } } } From f3abb8ed64506cc32ece834ee558319d354e27f7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Oct 2021 17:48:12 +0200 Subject: [PATCH 20/96] Display access key in Buttons. Set `RecognizesAccessKey` on button content presenter. --- samples/ControlCatalog/Pages/ButtonPage.xaml | 2 +- src/Avalonia.Themes.Default/Button.xaml | 1 + src/Avalonia.Themes.Fluent/Controls/Button.xaml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/samples/ControlCatalog/Pages/ButtonPage.xaml b/samples/ControlCatalog/Pages/ButtonPage.xaml index be114bbbc9..b35c112a68 100644 --- a/samples/ControlCatalog/Pages/ButtonPage.xaml +++ b/samples/ControlCatalog/Pages/ButtonPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/src/Avalonia.Themes.Default/Button.xaml b/src/Avalonia.Themes.Default/Button.xaml index 81d96aaa14..da36abe7ec 100644 --- a/src/Avalonia.Themes.Default/Button.xaml +++ b/src/Avalonia.Themes.Default/Button.xaml @@ -17,6 +17,7 @@ ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" Padding="{TemplateBinding Padding}" + RecognizesAccessKey="True" TextBlock.Foreground="{TemplateBinding Foreground}" HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"/> diff --git a/src/Avalonia.Themes.Fluent/Controls/Button.xaml b/src/Avalonia.Themes.Fluent/Controls/Button.xaml index 53d53ef127..533fabfb44 100644 --- a/src/Avalonia.Themes.Fluent/Controls/Button.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/Button.xaml @@ -34,6 +34,7 @@ Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" Padding="{TemplateBinding Padding}" + RecognizesAccessKey="True" HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" /> From 90e43897ee1473fa0b20395edc35f4133ae4915e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Oct 2021 17:48:31 +0200 Subject: [PATCH 21/96] Add access keys for menu items. --- samples/ControlCatalog/MainWindow.xaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index ee42e7a54b..375345f64e 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -63,11 +63,11 @@ - - + + - - + + From b663afe06b585bbd776dc14850f984c10d233a52 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Oct 2021 17:56:44 +0200 Subject: [PATCH 22/96] Make sure underline is drawn within bounds. --- src/Avalonia.Controls/Primitives/AccessText.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index c42c6f100c..3c82386991 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -68,7 +68,7 @@ namespace Avalonia.Controls.Primitives if (underscore != -1 && ShowAccessKey) { var rect = TextLayout.HitTestTextPosition(underscore); - var offset = new Vector(0, -0.5); + var offset = new Vector(0, -1.5); context.DrawLine( new Pen(Foreground, 1), rect.BottomLeft + offset, From fbfc1e4eb0ba33d32b60ec5879d4d6b5a25f4267 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 22 Oct 2021 16:57:11 +0100 Subject: [PATCH 23/96] restore osx window shadow fix. --- native/Avalonia.Native/src/OSX/window.mm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/Avalonia.Native/src/OSX/window.mm b/native/Avalonia.Native/src/OSX/window.mm index bd93de0e78..26c065fe11 100644 --- a/native/Avalonia.Native/src/OSX/window.mm +++ b/native/Avalonia.Native/src/OSX/window.mm @@ -52,7 +52,6 @@ public: [Window setBackingType:NSBackingStoreBuffered]; [Window setOpaque:false]; - [Window setContentView: StandardContainer]; } virtual HRESULT ObtainNSWindowHandle(void** ret) override @@ -125,6 +124,8 @@ public: SetPosition(lastPositionSet); UpdateStyle(); + [Window setContentView: StandardContainer]; + [Window setTitle:_lastTitle]; if(ShouldTakeFocusOnShow() && activate) @@ -323,6 +324,7 @@ public: BaseEvents->Resized(AvnSize{x,y}, reason); } + [StandardContainer setFrameSize:NSSize{x,y}]; [Window setContentSize:NSSize{x, y}]; } @finally From f98070fb4dcc281f85fa51a256d9762c772f3388 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 23 Oct 2021 14:37:48 +0200 Subject: [PATCH 24/96] Display access key in checkbox. --- src/Avalonia.Themes.Default/CheckBox.xaml | 1 + src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Avalonia.Themes.Default/CheckBox.xaml b/src/Avalonia.Themes.Default/CheckBox.xaml index 5e10b319a7..75d6f853be 100644 --- a/src/Avalonia.Themes.Default/CheckBox.xaml +++ b/src/Avalonia.Themes.Default/CheckBox.xaml @@ -41,6 +41,7 @@ ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" Margin="{TemplateBinding Padding}" + RecognizesAccessKey="True" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" IsVisible="{TemplateBinding Content, Converter={x:Static ObjectConverters.IsNotNull}}" diff --git a/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml b/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml index 7969cec947..ef28593711 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml @@ -44,6 +44,7 @@ ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" Margin="{TemplateBinding Padding}" + RecognizesAccessKey="True" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" Grid.Column="1" /> From d0037f1df50ddc33dacbe1639f8ab2aa6fa29ed9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 23 Oct 2021 14:38:54 +0200 Subject: [PATCH 25/96] Display access key in ControlCatalog pages. --- .../ControlCatalog/Pages/CheckBoxPage.xaml | 6 +++--- samples/ControlCatalog/Pages/DialogsPage.xaml | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/samples/ControlCatalog/Pages/CheckBoxPage.xaml b/samples/ControlCatalog/Pages/CheckBoxPage.xaml index 1359cfa2ef..769ef26699 100644 --- a/samples/ControlCatalog/Pages/CheckBoxPage.xaml +++ b/samples/ControlCatalog/Pages/CheckBoxPage.xaml @@ -11,9 +11,9 @@ Spacing="16"> - Unchecked - Checked - Indeterminate + _Unchecked + _Checked + _Indeterminate Disabled Use filters - - - - - - - - - - + + + + + + + + + + From 7f89c2a0dc97cea114b3315db9914a9d5c9115c0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 23 Oct 2021 14:38:57 +0200 Subject: [PATCH 26/96] Make button respond to access key. --- src/Avalonia.Controls/Button.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs index 614a18c6b5..8b22cdd4ec 100644 --- a/src/Avalonia.Controls/Button.cs +++ b/src/Avalonia.Controls/Button.cs @@ -99,6 +99,7 @@ namespace Avalonia.Controls CommandParameterProperty.Changed.Subscribe(CommandParameterChanged); IsDefaultProperty.Changed.Subscribe(IsDefaultChanged); IsCancelProperty.Changed.Subscribe(IsCancelChanged); + AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler