diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 86347d20..65472559 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -7,12 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 6.0.x 8.0.x - name: Install dependencies run: dotnet restore diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index ee72d94a..76be051b 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -1,15 +1,53 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Build Schema", - "$ref": "#/definitions/build", "definitions": { - "build": { - "type": "object", + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "CiAzureLinux", + "CiAzureOSX", + "CiAzureWindows", + "Clean", + "Compile", + "CreateNugetPackages", + "Package", + "RunCoreLibsTests", + "RunTests", + "ZipFiles" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "NukeBuild": { "properties": { - "Configuration": { - "type": "string", - "description": "configuration" - }, "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" @@ -19,24 +57,8 @@ "description": "Shows the help text for this build assembly" }, "Host": { - "type": "string", "description": "Host for execution. Default is 'automatic'", - "enum": [ - "AppVeyor", - "AzurePipelines", - "Bamboo", - "Bitrise", - "GitHubActions", - "GitLab", - "Jenkins", - "Rider", - "SpaceAutomation", - "TeamCity", - "Terminal", - "TravisCI", - "VisualStudio", - "VSCode" - ] + "$ref": "#/definitions/Host" }, "NoLogo": { "type": "boolean", @@ -57,22 +79,6 @@ "type": "string" } }, - "PublishFramework": { - "type": "string", - "description": "publish-framework" - }, - "PublishProject": { - "type": "string", - "description": "publish-project" - }, - "PublishRuntime": { - "type": "string", - "description": "publish-runtime" - }, - "PublishSelfContained": { - "type": "boolean", - "description": "publish-self-contained" - }, "Root": { "type": "string", "description": "Root directory during build execution" @@ -81,51 +87,50 @@ "type": "array", "description": "List of targets to be skipped. Empty list skips all dependencies", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "Pack", - "Publish", - "Restore", - "Test" - ] + "$ref": "#/definitions/ExecutableTarget" } }, - "Solution": { - "type": "string", - "description": "Path to a solution file that is automatically loaded" - }, "Target": { "type": "array", "description": "List of targets to be invoked. Default is '{default_target}'", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "Pack", - "Publish", - "Restore", - "Test" - ] + "$ref": "#/definitions/ExecutableTarget" } }, "Verbosity": { - "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", - "enum": [ - "Minimal", - "Normal", - "Quiet", - "Verbose" - ] + "$ref": "#/definitions/Verbosity" + } + } + } + }, + "allOf": [ + { + "properties": { + "Configuration": { + "type": "string", + "description": "configuration" }, - "VersionSuffix": { + "ForceNugetVersion": { + "type": "string", + "description": "force-nuget-version" + }, + "SkipPreviewer": { + "type": "boolean", + "description": "skip-previewer" + }, + "SkipTests": { + "type": "boolean", + "description": "skip-tests" + }, + "Solution": { "type": "string", - "description": "version-suffix" + "description": "Path to a solution file that is automatically loaded. Default is Avalonia.Controls.TreeDataGrid.sln" } } + }, + { + "$ref": "#/definitions/NukeBuild" } - } -} \ No newline at end of file + ] +} diff --git a/Directory.Build.props b/Directory.Build.props index b52a8875..a771f171 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,5 +13,6 @@ 11.0.0 + 11.2.8 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2c9ec504..e19a47f2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -7,6 +7,12 @@ pool: vmImage: ubuntu-latest steps: + +- task: UseDotNet@2 + displayName: 'Use .NET SDK' + inputs: + version: 8.0.x + - task: CmdLine@2 displayName: 'Install Nuke' inputs: diff --git a/global.json b/global.json deleted file mode 100644 index d3492186..00000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "8.0.101", - "rollForward": "latestFeature" - } -} diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index 3427f354..a48300e8 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -6,12 +6,12 @@ using System.Linq; using System.Runtime.InteropServices; using Nuke.Common; +using Nuke.Common.IO; using Nuke.Common.ProjectModel; using Nuke.Common.Tooling; using Nuke.Common.Tools.DotNet; using Nuke.Common.Tools.MSBuild; using static Nuke.Common.EnvironmentInfo; -using static Nuke.Common.IO.FileSystemTasks; using static Nuke.Common.Tools.MSBuild.MSBuildTasks; using static Nuke.Common.Tools.DotNet.DotNetTasks; using static Nuke.Common.Tools.VSWhere.VSWhereTasks; @@ -82,8 +82,7 @@ IReadOnlyCollection MsBuildCommon( return MSBuild(c => c .SetProjectFile(projectFile) // This is required for VS2019 image on Azure Pipelines - .When(Parameters.IsRunningOnWindows && - Parameters.IsRunningOnAzure, _ => _ + .When(_ => Parameters.IsRunningOnWindows && Parameters.IsRunningOnAzure, _ => _ .AddProperty("JavaSdkDirectory", GetVariable("JAVA_HOME_11_X64"))) .AddProperty("PackageVersion", Parameters.Version) .AddProperty("iOSRoslynPathHackRequired", true) @@ -95,13 +94,13 @@ IReadOnlyCollection MsBuildCommon( Target Clean => _ => _.Executes(() => { - Parameters.BuildDirs.ForEach(DeleteDirectory); - Parameters.BuildDirs.ForEach(EnsureCleanDirectory); - EnsureCleanDirectory(Parameters.ArtifactsDir); - EnsureCleanDirectory(Parameters.NugetIntermediateRoot); - EnsureCleanDirectory(Parameters.NugetRoot); - EnsureCleanDirectory(Parameters.ZipRoot); - EnsureCleanDirectory(Parameters.TestResultsRoot); + Parameters.BuildDirs.ForEach(p => p.DeleteDirectory()); + Parameters.BuildDirs.ForEach(p => p.CreateOrCleanDirectory()); + Parameters.ArtifactsDir.CreateOrCleanDirectory(); + Parameters.NugetIntermediateRoot.CreateOrCleanDirectory(); + Parameters.NugetRoot.CreateOrCleanDirectory(); + Parameters.ZipRoot.CreateOrCleanDirectory(); + Parameters.TestResultsRoot.CreateOrCleanDirectory(); }); Target Compile => _ => _ @@ -110,7 +109,7 @@ IReadOnlyCollection MsBuildCommon( { if (Parameters.IsRunningOnWindows) MsBuildCommon(Parameters.MSBuildSolution, c => c - .SetProcessArgumentConfigurator(a => a.Add("/r")) + .SetProcessAdditionalArguments("/r") .AddTargets("Build") ); @@ -122,41 +121,23 @@ IReadOnlyCollection MsBuildCommon( ); }); - void RunCoreTest(string projectName) - { - Information($"Running tests from {projectName}"); - var project = Solution.GetProject(projectName).NotNull("project != null"); - - foreach (var fw in project.GetTargetFrameworks()) - { - if (fw.StartsWith("net4") - && RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - && Environment.GetEnvironmentVariable("FORCE_LINUX_TESTS") != "1") - { - Information($"Skipping {projectName} ({fw}) tests on Linux - https://github.com/mono/mono/issues/13969"); - continue; - } - - Information($"Running for {projectName} ({fw}) ..."); - - DotNetTest(c => c - .SetProjectFile(project) - .SetConfiguration(Parameters.Configuration) - .SetFramework(fw) - .EnableNoBuild() - .EnableNoRestore() - .When(Parameters.PublishTestResults, _ => _ - .SetLoggers("trx") - .SetResultsDirectory(Parameters.TestResultsRoot))); - } - } - Target RunCoreLibsTests => _ => _ .OnlyWhenStatic(() => !Parameters.SkipTests) .DependsOn(Compile) .Executes(() => { - RunCoreTest("Avalonia.Controls.TreeDataGrid.Tests"); + foreach (var testProject in (RootDirectory / "tests").GlobFiles("**/*.csproj")) + { + Information($"Running tests from {testProject}"); + DotNetTest(c => c + .SetProjectFile(testProject) + .SetConfiguration(Parameters.Configuration) + .EnableNoBuild() + .EnableNoRestore() + .When(_ => Parameters.PublishTestResults, _ => _ + .SetLoggers("trx") + .SetResultsDirectory(Parameters.TestResultsRoot))); + } }); Target ZipFiles => _ => _ diff --git a/nukebuild/BuildParameters.cs b/nukebuild/BuildParameters.cs index 43a25e78..d68286e5 100644 --- a/nukebuild/BuildParameters.cs +++ b/nukebuild/BuildParameters.cs @@ -56,7 +56,7 @@ public class BuildParameters public AbsolutePath BinRoot { get; } public AbsolutePath TestResultsRoot { get; } public string DirSuffix { get; } - public List BuildDirs { get; } + public List BuildDirs { get; } public string FileZipSuffix { get; } public AbsolutePath ZipCoreArtifacts { get; } public AbsolutePath ZipNuGetArtifacts { get; } @@ -125,7 +125,7 @@ public BuildParameters(Build b) ZipRoot = ArtifactsDir / "zip"; BinRoot = ArtifactsDir / "bin"; TestResultsRoot = ArtifactsDir / "test-results"; - BuildDirs = GlobDirectories(RootDirectory, "**bin").Concat(GlobDirectories(RootDirectory, "**obj")).ToList(); + BuildDirs = RootDirectory.GlobDirectories("**bin").Concat(RootDirectory.GlobDirectories("**obj")).ToList(); DirSuffix = Configuration; FileZipSuffix = Version + ".zip"; ZipCoreArtifacts = ZipRoot / ("Avalonia-" + FileZipSuffix); diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj index d387b765..497faaa7 100644 --- a/nukebuild/_build.csproj +++ b/nukebuild/_build.csproj @@ -2,17 +2,17 @@ Exe - net6.0 + net8.0 false False CS0649;CS0169 + 1 - - - + + diff --git a/readme.md b/readme.md index ea2f9445..6f8ed38e 100644 --- a/readme.md +++ b/readme.md @@ -20,10 +20,6 @@ An example of `TreeDataGrid` displaying flat data: ## Current Status -The control is currently in *early beta*. As such there will be bugs, missing features and lacking docs, but the control should be generally usable and performant. - -**Note**: - We accept issues and pull requests but we answer and review only pull requests and issues that are created by our customers. It's a quite big project and servicing all issues and pull requests will require more time than we have. But feel free to open issues and pull requests because they may be useful for us! ## Getting Started diff --git a/samples/TreeDataGridDemo/MainWindow.axaml.cs b/samples/TreeDataGridDemo/MainWindow.axaml.cs index 883cac22..4a1bcdd0 100644 --- a/samples/TreeDataGridDemo/MainWindow.axaml.cs +++ b/samples/TreeDataGridDemo/MainWindow.axaml.cs @@ -107,7 +107,7 @@ private void DragDrop_RowDragStarted(object? sender, TreeDataGridRowDragStartedE private void DragDrop_RowDragOver(object? sender, TreeDataGridRowDragEventArgs e) { if (e.Position == TreeDataGridRowDropPosition.Inside && - e.TargetRow.Model is DragDropItem i && + e.TargetRow?.Model is DragDropItem i && !i.AllowDrop) e.Inner.DragEffects = DragDropEffects.None; } diff --git a/samples/TreeDataGridDemo/TreeDataGridDemo.csproj b/samples/TreeDataGridDemo/TreeDataGridDemo.csproj index 055ae9ac..44fa1c27 100644 --- a/samples/TreeDataGridDemo/TreeDataGridDemo.csproj +++ b/samples/TreeDataGridDemo/TreeDataGridDemo.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 False WinExe @@ -12,12 +12,12 @@ - - - - - - + + + + + + diff --git a/samples/TreeDataGridDemo/ViewModels/WikipediaPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/WikipediaPageViewModel.cs index f895169b..1b1dc7a6 100644 --- a/samples/TreeDataGridDemo/ViewModels/WikipediaPageViewModel.cs +++ b/samples/TreeDataGridDemo/ViewModels/WikipediaPageViewModel.cs @@ -45,7 +45,7 @@ private async Task LoadContent() var client = new HttpClient(); var d = DateTimeOffset.Now.Day; var m = DateTimeOffset.Now.Month; - var uri = $"https://api.wikimedia.org/feed/v1/wikipedia/en/onthisday/all/{m}/{d}"; + var uri = $"https://api.wikimedia.org/feed/v1/wikipedia/en/onthisday/all/{m:00}/{d:00}"; var s = await client.GetStringAsync(uri); var data = JsonSerializer.Deserialize(s, new JsonSerializerOptions { diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs index bd89b6ef..f11abfab 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs @@ -17,6 +17,19 @@ public ExpressionChainVisitor(LambdaExpression expression) _rootExpression = expression; } + /// + /// Builds an array of delegates which return the intermediate objects in the expression chain. + /// + /// For example, if the expression is x => x.Foo.Bar.Baz then the links will be: + /// + /// - x => x + /// - x => x.Foo + /// - x => x.Foo.Bar + /// + /// There is no delegate for the final property of the expression x => x.Foo.Bar.Baz. + /// + /// + public static Func[] Build(Expression> expression) { var visitor = new ExpressionChainVisitor(expression); diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs index b19341af..b1314989 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs @@ -308,15 +308,26 @@ private int ChainIndexOf(object o) private void ChainPropertyChanged(object? sender) { - if (sender is null) + if (sender is null || _chain is null) return; - var index = ChainIndexOf(sender); + var senderIndex = ChainIndexOf(sender); - if (index != -1) + if (senderIndex == -1) { - StopListeningToChain(index); - ListenToChain(index); + // If the sender is not in the chain, we should stop listening to it. + UnsubscribeToChanges(sender); + return; + } + + // The member that changed is the next one in the chain. + var changedMemberIndex = senderIndex + 1; + + // Update subscriptions on the changed member and the links after it. + if (changedMemberIndex < _chain.Length) + { + StopListeningToChain(from: changedMemberIndex); + ListenToChain(from: changedMemberIndex); } PublishValue(); diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs index b66f4b0d..b2c6727c 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs @@ -64,7 +64,8 @@ public static TypedBinding TwoWay(Expression> e (o, v) => property.SetValue(o, v) : (root, v) => { - var o = links[^2](root); + // The last link points the object containing the property to set + var o = links[^1](root); property.SetValue(o, v); }; diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs index 5b21ff2c..4d2e9a94 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs @@ -134,6 +134,18 @@ bool IUpdateColumnLayout.CommitActualWidth() var oldWidth = ActualWidth; ActualWidth = width; _starWidthWasConstrained = false; + + // MathUtilites.AreClose will return true for this condition. + // If the user has auto columns that are not yet realized, then the + // _autoWidth will remain NaN. + // This will lead to an endless layout cycle causing the whole UI + // to have degraded performance, until all columns have an actual value + // set for _autoWidth. + if (double.IsNaN(oldWidth) && double.IsNaN(ActualWidth)) + { + return false; + } + return !MathUtilities.AreClose(oldWidth, ActualWidth); } diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 11e88c52..b95dd152 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -41,6 +41,7 @@ public abstract class TreeDataGridPresenterBase : Border private bool _isInLayout; private bool _isWaitingForViewportUpdate; private IReadOnlyList? _items; + private bool _isSubscribedToItemChanges; private RealizedStackElements? _measureElements; private RealizedStackElements? _realizedElements; private ScrollViewer? _scrollViewer; @@ -53,7 +54,6 @@ public TreeDataGridPresenterBase() _recycleElement = RecycleElement; _recycleElementOnItemRemoved = RecycleElementOnItemRemoved; _updateElementIndex = UpdateElementIndex; - EffectiveViewportChanged += OnEffectiveViewportChanged; } public TreeDataGridElementFactory? ElementFactory @@ -69,14 +69,12 @@ public IReadOnlyList? Items { if (_items != value) { - if (_items is INotifyCollectionChanged oldIncc) - oldIncc.CollectionChanged -= OnItemsCollectionChanged; + UnsubscribeFromItemChanges(); var oldValue = _items; _items = value; - if (_items is INotifyCollectionChanged newIncc) - newIncc.CollectionChanged += OnItemsCollectionChanged; + SubscribeToItemChanges(); RaisePropertyChanged( ItemsProperty, @@ -415,12 +413,22 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); _scrollViewer = this.FindAncestorOfType(); + + // Subscribing to this event adds a reference to 'this' in the layout manager. + // so this must be unsubscribed to avoid memory leaks. + EffectiveViewportChanged += OnEffectiveViewportChanged; + + SubscribeToItemChanges(); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); _scrollViewer = null; + + EffectiveViewportChanged -= OnEffectiveViewportChanged; + + UnsubscribeFromItemChanges(); } protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) @@ -460,6 +468,24 @@ protected virtual void UnrealizeElementOnItemRemoved(Control element) UnrealizeElement(element); } + private void SubscribeToItemChanges() + { + if (!_isSubscribedToItemChanges && _items is INotifyCollectionChanged newIncc) + { + newIncc.CollectionChanged += OnItemsCollectionChanged; + _isSubscribedToItemChanges = true; + } + } + + private void UnsubscribeFromItemChanges() + { + if (_isSubscribedToItemChanges && _items is INotifyCollectionChanged oldIncc) + { + oldIncc.CollectionChanged -= OnItemsCollectionChanged; + _isSubscribedToItemChanges = false; + } + } + private void RealizeElements( IReadOnlyList items, Size availableSize, diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs index 7f345e48..c9ad891a 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs @@ -152,9 +152,17 @@ protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); - var delta = e.GetPosition(this) - _mouseDownPosition; + var currentPoint = e.GetCurrentPoint(this); + var delta = currentPoint.Position - _mouseDownPosition; - if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || + var pointerSupportsDrag = currentPoint.Pointer.Type switch + { + PointerType.Mouse => currentPoint.Properties.IsLeftButtonPressed, + PointerType.Pen => currentPoint.Properties.IsRightButtonPressed, + _ => false + }; + + if (!pointerSupportsDrag || e.Handled || Math.Abs(delta.X) < DragDistance && Math.Abs(delta.Y) < DragDistance || _mouseDownPosition == s_InvalidPoint) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs index 19d709ef..4f887dbd 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs @@ -140,12 +140,19 @@ void ITreeDataGridSelectionInteraction.OnPointerPressed(TreeDataGrid sender, Poi // Select a cell on pointer pressed if: // // - It's a mouse click, not touch: we don't want to select on touch scroll gesture start + // - It's a pen secondary button press, we don't want to select on primary button scroll gesture start // - The cell isn't already selected: we don't want to deselect an existing multiple selection // if the user is trying to drag multiple cells // // Otherwise select on pointer release. + var pointerSupportSelectionOnPress = e.Pointer.Type switch + { + PointerType.Mouse => true, + PointerType.Pen => e.GetCurrentPoint(null).Properties.IsRightButtonPressed, + _ => false + }; if (!e.Handled && - e.Pointer.Type == PointerType.Mouse && + pointerSupportSelectionOnPress && e.Source is Control source && sender.TryGetCell(source, out var cell) && !IsSelected(cell.ColumnIndex, cell.RowIndex)) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs index ec112459..bd4da5d7 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs @@ -318,12 +318,19 @@ void ITreeDataGridSelectionInteraction.OnPointerPressed(TreeDataGrid sender, Poi // Select a row on pointer pressed if: // // - It's a mouse click, not touch: we don't want to select on touch scroll gesture start + // - It's a pen secondary button press, we don't want to select on primary button scroll gesture start // - The row isn't already selected: we don't want to deselect an existing multiple selection // if the user is trying to drag multiple rows // // Otherwise select on pointer release. + var pointerSupportSelectionOnPress = e.Pointer.Type switch + { + PointerType.Mouse => true, + PointerType.Pen => e.GetCurrentPoint(null).Properties.IsRightButtonPressed, + _ => false + }; if (!e.Handled && - e.Pointer.Type == PointerType.Mouse && + pointerSupportSelectionOnPress && e.Source is Control source && sender.TryGetRow(source, out var row) && _source.Rows.RowIndexToModelIndex(row.RowIndex) is { } modelIndex && diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml index ecbd716b..662fc59f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml @@ -146,12 +146,12 @@ diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index 1483405a..1a8728a6 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -607,7 +607,7 @@ private void AutoScroll(bool direction) [MemberNotNullWhen(true, nameof(_source))] private bool CalculateAutoDragDrop( - TreeDataGridRow targetRow, + TreeDataGridRow? targetRow, DragEventArgs e, [NotNullWhen(true)] out DragInfo? data, out TreeDataGridRowDropPosition position) @@ -616,6 +616,7 @@ private bool CalculateAutoDragDrop( e.Data.Get(DragInfo.DataFormat) is not DragInfo di || _source is null || _source.IsSorted || + targetRow is null || di.Source != _source) { data = null; @@ -647,7 +648,6 @@ private void OnDragOver(DragEventArgs e) if (!TryGetRow(e.Source as Control, out var row)) { e.DragEffects = DragDropEffects.None; - return; } if (!CalculateAutoDragDrop(row, e, out _, out var adorner)) @@ -663,7 +663,10 @@ private void OnDragOver(DragEventArgs e) adorner = ev.Position; } - ShowDragAdorner(row, adorner); + if (row != null) + { + ShowDragAdorner(row, adorner); + } if (Scroll is ScrollViewer scroller) { @@ -687,8 +690,7 @@ private void OnDrop(DragEventArgs e) { StopDrag(); - if (!TryGetRow(e.Source as Control, out var row)) - return; + TryGetRow(e.Source as Control, out var row); var autoDrop = CalculateAutoDragDrop(row, e, out var data, out var position); var route = BuildEventRoute(RowDropEvent); @@ -707,6 +709,7 @@ private void OnDrop(DragEventArgs e) if (autoDrop && _source is not null && + row is not null && position != TreeDataGridRowDropPosition.None) { var targetIndex = _source.Rows.RowIndexToModelIndex(row.RowIndex); diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGridRowDragEventArgs.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGridRowDragEventArgs.cs index 7fc5447a..35abc1dd 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGridRowDragEventArgs.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGridRowDragEventArgs.cs @@ -24,7 +24,7 @@ public class TreeDataGridRowDragEventArgs : RoutedEventArgs /// The event being raised. /// The row that is being dragged over. /// The inner drag event args. - public TreeDataGridRowDragEventArgs(RoutedEvent routedEvent, TreeDataGridRow row, DragEventArgs inner) + public TreeDataGridRowDragEventArgs(RoutedEvent routedEvent, TreeDataGridRow? row, DragEventArgs inner) : base(routedEvent) { TargetRow = row; @@ -39,7 +39,7 @@ public TreeDataGridRowDragEventArgs(RoutedEvent routedEvent, TreeDataGridRow row /// /// Gets the row being dragged over. /// - public TreeDataGridRow TargetRow { get; } + public TreeDataGridRow? TargetRow { get; } /// /// Gets or sets a value indicating the how the data should be dropped into diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Avalonia.Controls.TreeDataGrid.Tests.csproj b/tests/Avalonia.Controls.TreeDataGrid.Tests/Avalonia.Controls.TreeDataGrid.Tests.csproj index cc5443b9..397a2017 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Avalonia.Controls.TreeDataGrid.Tests.csproj +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Avalonia.Controls.TreeDataGrid.Tests.csproj @@ -1,12 +1,12 @@ - net6.0 + net8.0 False Avalonia.Controls.TreeDataGridTests - - + + all diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Bindings/TypedBindingTests.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Bindings/TypedBindingTests.cs new file mode 100644 index 00000000..0e0f1110 --- /dev/null +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Bindings/TypedBindingTests.cs @@ -0,0 +1,793 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Data; +using Avalonia.Experimental.Data; +using Avalonia.Headless.XUnit; +using Xunit; + +namespace Avalonia.Controls.TreeDataGridTests.Bindings; + +public class TypedBindingTests +{ + [AvaloniaFact] + public void OneWay_Binding_Single_Link_Should_Get_Value() + { + // Arrange + var source = new TestViewModel { Name = "Test" }; + var target = new TestTarget(); + + // Create a binding with a single link (source.Name) + var binding = TypedBinding.OneWay(vm => vm.Name); + var expression = binding.Instance(source); + + // Act + target.Bind(TestTarget.TextProperty, expression); + + // Assert + Assert.Equal("Test", target.Text); + } + + [AvaloniaFact] + public void OneWay_Binding_Single_Link_Should_Listen_To_Changes() + { + // Arrange + var source = new TestViewModel { Name = "Test" }; + var target = new TestTarget(); + + // Create a binding with a single link (source.Name) + var binding = TypedBinding.OneWay(vm => vm.Name); + var expression = binding.Instance(source); + + // Act + target.Bind(TestTarget.TextProperty, expression); + source.Name = "Updated"; + + // Assert + Assert.Equal("Updated", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Single_Link_Should_Listen_And_Write() + { + // Arrange + var source = new TestViewModel { Name = "Test" }; + var target = new TestTarget(); + + // Create a binding with a single link (source.Name) + var binding = TypedBinding.TwoWay(vm => vm.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - set up binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - change source + source.Name = "Updated"; + + // Assert + Assert.Equal("Updated", target.Text); + + // Act - change target + target.Text = "UpdatedFromTarget"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("UpdatedFromTarget", source.Name); + } + + [AvaloniaFact] + public void OneWay_Binding_Two_Links_Should_Get_Value() + { + // Arrange + var child = new TestViewModel { Name = "Child" }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with two links (source.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Name); + var expression = binding.Instance(source); + + // Act + target.Bind(TestTarget.TextProperty, expression); + + // Assert + Assert.Equal("Child", target.Text); + } + + [AvaloniaFact] + public void OneWay_Binding_Two_Links_Should_Listen_To_Changes() + { + // Arrange + var child = new TestViewModel { Name = "Child" }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with two links (source.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Name); + var expression = binding.Instance(source); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update leaf property + child.Name = "UpdatedChild"; + + // Assert + Assert.Equal("UpdatedChild", target.Text); + + // Act - update intermediate property + var newChild = new TestViewModel { Name = "NewChild" }; + source.Child = newChild; + + // Assert + Assert.Equal("NewChild", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Two_Links_Should_Listen_And_Write() + { + // Arrange + var child = new TestViewModel { Name = "Child" }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with two links (source.Child.Name) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update from source + child.Name = "UpdatedChild"; + + // Assert + Assert.Equal("UpdatedChild", target.Text); + + // Act - update from target + target.Text = "UpdatedFromTarget"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("UpdatedFromTarget", child.Name); + + // Act - change intermediate link + var newChild = new TestViewModel { Name = "NewChild" }; + source.Child = newChild; + + // Assert + Assert.Equal("NewChild", target.Text); + + // Act - update from target after changing intermediate + target.Text = "FinalUpdate"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("FinalUpdate", newChild.Name); + } + + [AvaloniaFact] + public void OneWay_Binding_Three_Links_Should_Get_Value() + { + // Arrange + var grandChild = new TestViewModel { Name = "GrandChild" }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with three links (source.Child.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Child!.Name); + var expression = binding.Instance(source); + + // Act + target.Bind(TestTarget.TextProperty, expression); + + // Assert + Assert.Equal("GrandChild", target.Text); + } + + [AvaloniaFact] + public void OneWay_Binding_Three_Links_Should_Listen_To_Changes() + { + // Arrange + var grandChild = new TestViewModel { Name = "GrandChild" }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with three links (source.Child.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Child!.Name); + var expression = binding.Instance(source); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update leaf property + grandChild.Name = "UpdatedGrandChild"; + + // Assert + Assert.Equal("UpdatedGrandChild", target.Text); + + // Act - update middle property + var newGrandChild = new TestViewModel { Name = "NewGrandChild" }; + child.Child = newGrandChild; + + // Assert + Assert.Equal("NewGrandChild", target.Text); + + // Act - update root property + var newChildWithGrandChild = new TestViewModel + { + Name = "NewChild", + Child = new TestViewModel { Name = "NewestGrandChild" } + }; + source.Child = newChildWithGrandChild; + + // Assert + Assert.Equal("NewestGrandChild", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Three_Links_Should_Listen_And_Write() + { + // Arrange + var grandChild = new TestViewModel { Name = "GrandChild" }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with three links (source.Child.Child.Name) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Child!.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - set from target + target.Text = "UpdatedFromTarget"; + binding.Write!.Invoke(source, target.Text); + + + // Assert + Assert.Equal("UpdatedFromTarget", grandChild.Name); + + // Act - change intermediate node and update from target + var newGrandChild = new TestViewModel { Name = "NewGrandChild" }; + child.Child = newGrandChild; + + // Assert initial update + Assert.Equal("NewGrandChild", target.Text); + + // Act - update again from target + target.Text = "AfterIntermediateChange"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("AfterIntermediateChange", newGrandChild.Name); + } + + [AvaloniaFact] + public void OneWay_Binding_Four_Links_Should_Get_Value() + { + // Arrange + var greatGrandChild = new TestViewModel { Name = "GreatGrandChild" }; + var grandChild = new TestViewModel { Name = "GrandChild", Child = greatGrandChild }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with four links (source.Child.Child.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Child!.Child!.Name); + var expression = binding.Instance(source); + + // Act + target.Bind(TestTarget.TextProperty, expression); + + // Assert + Assert.Equal("GreatGrandChild", target.Text); + } + + [AvaloniaFact] + public void OneWay_Binding_Four_Links_Should_Listen_To_Changes() + { + // Arrange + var greatGrandChild = new TestViewModel { Name = "GreatGrandChild" }; + var grandChild = new TestViewModel { Name = "GrandChild", Child = greatGrandChild }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with four links (source.Child.Child.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Child!.Child!.Name); + var expression = binding.Instance(source); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update leaf node + greatGrandChild.Name = "UpdatedGreatGrandChild"; + + // Assert + Assert.Equal("UpdatedGreatGrandChild", target.Text); + + // Act - update middle node + var newGreatGrandChild = new TestViewModel { Name = "NewGreatGrandChild" }; + grandChild.Child = newGreatGrandChild; + + // Assert + Assert.Equal("NewGreatGrandChild", target.Text); + + // Act - update higher node + var newGrandChildWithChild = new TestViewModel + { + Name = "NewGrandChild", + Child = new TestViewModel { Name = "BrandNewGreatGrandChild" } + }; + child.Child = newGrandChildWithChild; + + // Assert + Assert.Equal("BrandNewGreatGrandChild", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Four_Links_Should_Listen_And_Write() + { + // Arrange + var greatGrandChild = new TestViewModel { Name = "GreatGrandChild" }; + var grandChild = new TestViewModel { Name = "GrandChild", Child = greatGrandChild }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with four links (source.Child.Child.Child.Name) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Child!.Child!.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - set from target + target.Text = "UpdatedFromTarget"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("UpdatedFromTarget", greatGrandChild.Name); + + // Act - change leaf node + var newGreatGrandChild = new TestViewModel { Name = "NewGreatGrandChild" }; + grandChild.Child = newGreatGrandChild; + + // Assert + Assert.Equal("NewGreatGrandChild", target.Text); + + // Act - update from target + target.Text = "AfterLeafChange"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("AfterLeafChange", newGreatGrandChild.Name); + + // Act - update middle node + var newGrandChildWithChild = new TestViewModel + { + Name = "NewGrandChild", + Child = new TestViewModel { Name = "BrandNewGreatGrandChild" } + }; + child.Child = newGrandChildWithChild; + + // Assert + Assert.Equal("BrandNewGreatGrandChild", target.Text); + + // Act - update from target + target.Text = "AfterMiddleChange"; + binding.Write!.Invoke(source, target.Text); + + // Assert + Assert.Equal("AfterMiddleChange", newGrandChildWithChild.Child.Name); + } + + [AvaloniaFact] + public void Binding_Should_Handle_Null_Intermediate_Values() + { + // Arrange + var source = new TestViewModel { Name = "Parent", Child = null }; + var target = new TestTarget(); + + // Create a binding that will encounter a null (source.Child.Name) + var binding = TypedBinding.OneWay(vm => vm.Child!.Name); + var expression = binding.Instance(source); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Assert - binding should have error state due to null reference + Assert.Null(target.Text); // Default value since binding path fails + + // Act - fix the null reference + var newChild = new TestViewModel { Name = "NewChild" }; + source.Child = newChild; + + // Assert - binding should work now + Assert.Equal("NewChild", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Single_Link_Should_Update_Via_OnNext() + { + // Arrange + var source = new TestViewModel { Name = "Test" }; + var target = new TestTarget(); + + // Create a binding with a single link (source.Name) + var binding = TypedBinding.TwoWay(vm => vm.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - set up binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update via OnNext + expression.OnNext("UpdatedViaOnNext"); + + // Assert + Assert.Equal("UpdatedViaOnNext", source.Name); + Assert.Equal("UpdatedViaOnNext", target.Text); + } + + [AvaloniaFact] + public void TwoWay_Binding_Two_Links_Should_Update_Via_OnNext() + { + // Arrange + var child = new TestViewModel { Name = "Child" }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with two links (source.Child.Name) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update via OnNext + expression.OnNext("UpdatedViaOnNext"); + + // Assert + Assert.Equal("UpdatedViaOnNext", child.Name); + Assert.Equal("UpdatedViaOnNext", target.Text); + + // Act - change intermediate link + var newChild = new TestViewModel { Name = "NewChild" }; + source.Child = newChild; + + // Assert + Assert.Equal("NewChild", target.Text); + + // Act - update via OnNext after changing intermediate + expression.OnNext("AfterIntermediateChange"); + + // Assert + Assert.Equal("AfterIntermediateChange", newChild.Name); + } + + [AvaloniaFact] + public void TwoWay_Binding_Three_Links_Should_Update_Via_OnNext() + { + // Arrange + var grandChild = new TestViewModel { Name = "GrandChild" }; + var child = new TestViewModel { Name = "Child", Child = grandChild }; + var source = new TestViewModel { Name = "Parent", Child = child }; + var target = new TestTarget(); + + // Create a binding with three links (source.Child.Child.Name) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Child!.Name); + var expression = binding.Instance(source, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestTarget.TextProperty, expression); + + // Act - update via OnNext + expression.OnNext("UpdatedViaOnNext"); + + // Assert + Assert.Equal("UpdatedViaOnNext", grandChild.Name); + + // Act - change intermediate node + var newGrandChild = new TestViewModel { Name = "NewGrandChild" }; + child.Child = newGrandChild; + + // Assert initial update + Assert.Equal("NewGrandChild", target.Text); + + // Act - update via OnNext after changing intermediate + expression.OnNext("AfterIntermediateChange"); + + // Assert + Assert.Equal("AfterIntermediateChange", newGrandChild.Name); + } + + [AvaloniaFact] + public void TwoWay_Binding_Should_Propagate_IsExpanded_Toggle() + { + // Arrange + var node = new TestExpandableNode { IsExpanded = false }; + var target = new TestExpandableTarget(); + + // Create a binding for IsExpanded property (like in FileTreeNodeModel) + var binding = TypedBinding.TwoWay(vm => vm.IsExpanded); + var expression = binding.Instance(node, BindingMode.TwoWay); + + // Act - initialize binding + target.Bind(TestExpandableTarget.IsExpandedProperty, expression, binding, node); + + // Assert initial state + Assert.False(target.IsExpanded); + Assert.False(node.IsExpanded); + Assert.Equal(0, node.ExpandCallCount); + Assert.Equal(0, node.CollapseCallCount); + + // Act - simulate ToggleExpandedCommand by directly toggling the property + node.IsExpanded = true; + + // Assert expanded state propagated to target + Assert.True(target.IsExpanded); + Assert.True(node.IsExpanded); + Assert.Equal(1, node.ExpandCallCount); + Assert.Equal(0, node.CollapseCallCount); + + // Act - toggle back to collapsed + node.IsExpanded = false; + + // Assert collapsed state propagated to target + Assert.False(target.IsExpanded); + Assert.False(node.IsExpanded); + Assert.Equal(1, node.ExpandCallCount); + Assert.Equal(1, node.CollapseCallCount); + + // Act - toggle via target property, which should invoke Write + node.IsExpanded = true; + + // Assert expanded state propagated to target + Assert.True(target.IsExpanded); + Assert.True(node.IsExpanded); + Assert.Equal(2, node.ExpandCallCount); + Assert.Equal(1, node.CollapseCallCount); + } + + [AvaloniaFact] + public void TwoWay_Binding_Should_Work_With_Multiple_Binding_Expressions() + { + // Arrange + var node = new TestExpandableNode { IsExpanded = false }; + var target1 = new TestExpandableTarget(); + var target2 = new TestExpandableTarget(); + + // Create a binding for IsExpanded property + var binding = TypedBinding.TwoWay(vm => vm.IsExpanded); + + // Create two separate binding expressions from the same binding + var expression1 = binding.Instance(node, BindingMode.TwoWay); + var expression2 = binding.Instance(node, BindingMode.TwoWay); + + // Initialize bindings + target1.Bind(TestExpandableTarget.IsExpandedProperty, expression1, binding, node); + target2.Bind(TestExpandableTarget.IsExpandedProperty, expression2, binding, node); + + // Act - change from model + node.IsExpanded = true; + + // Assert both targets updated + Assert.True(target1.IsExpanded); + Assert.True(target2.IsExpanded); + + // Act - change from one target + target1.IsExpanded = false; + + // Assert all synchronized + Assert.False(node.IsExpanded); + Assert.False(target2.IsExpanded); + } + + [AvaloniaFact] + public void TwoWay_Binding_Should_Work_With_Multiple_Binding_Expressions_And_Long_Chain() + { + // Create a deep chain of 5 TestViewModels + var level5 = new TestViewModel { Name = "Level5" }; + var level4 = new TestViewModel { Name = "Level4", Child = level5 }; + var level3 = new TestViewModel { Name = "Level3", Child = level4 }; + var level2 = new TestViewModel { Name = "Level2", Child = level3 }; + var level1 = new TestViewModel { Name = "Level1", Child = level2 }; + var root = new TestViewModel { Name = "Root", Child = level1 }; + + // Create simple targets with a Text property + var target1 = new TestTarget(); + var target2 = new TestTarget(); + + // Create a binding for the deep Name property (5 levels deep) + var binding = TypedBinding.TwoWay(vm => vm.Child!.Child!.Child!.Child!.Child!.Name); + + // Create two separate binding expressions from the same binding + var expression1 = binding.Instance(root, BindingMode.TwoWay); + var expression2 = binding.Instance(root, BindingMode.TwoWay); + + // Initialize bindings + target1.Bind(TestTarget.TextProperty, expression1); + target2.Bind(TestTarget.TextProperty, expression2); + + // Assert initial binding + Assert.Equal("Level5", target1.Text); + Assert.Equal("Level5", target2.Text); + + // Act - change leaf node property + level5.Name = "Updated Level5"; + + // Assert both targets updated + Assert.Equal("Updated Level5", target1.Text); + Assert.Equal("Updated Level5", target2.Text); + + // Act - replace a middle node in the chain + var newLevel4 = new TestViewModel + { + Name = "New Level4", + Child = new TestViewModel { Name = "New Level5" } + }; + level3.Child = newLevel4; + + // Assert both targets updated to the new path + Assert.Equal("New Level5", target1.Text); + Assert.Equal("New Level5", target2.Text); + + // Act - change leaf node property on the old path + level5.Name = "Changed from Old Path"; + + // Assert both targets still point to the new path + Assert.Equal("New Level5", target1.Text); + Assert.Equal("New Level5", target2.Text); + + // Act - update from target after chain replacement + target1.Text = "Changed from Target1"; + binding.Write!.Invoke(root, target1.Text); + + // Assert the new leaf node and other target got updated + Assert.Equal("Changed from Target1", newLevel4.Child!.Name); + Assert.Equal("Changed from Target1", target2.Text); + + // Act - replace another node higher in the chain + var newLevel2 = new TestViewModel + { + Name = "New Level2", + Child = new TestViewModel + { + Name = "Newest Level3", + Child = new TestViewModel + { + Name = "Newest Level4", + Child = new TestViewModel + { + Name = "Newest Level5" + } + } + } + }; + level1.Child = newLevel2; + + // Assert both targets updated to the new deeply nested chain + Assert.Equal("Newest Level5", target1.Text); + Assert.Equal("Newest Level5", target2.Text); + + // Act - update from target after major chain replacement + target2.Text = "Final value"; + binding.Write!.Invoke(root, target2.Text); + + // Assert the new deep leaf node and other target got updated + Assert.Equal("Final value", newLevel2.Child!.Child!.Child!.Name); + Assert.Equal("Final value", target1.Text); + } + + + // Test model that tracks expand/collapse operations + private class TestExpandableNode : INotifyPropertyChanged + { + private bool _isExpanded; + + public int ExpandCallCount { get; private set; } + public int CollapseCallCount { get; private set; } + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded != value) + { + _isExpanded = value; + + // Simulate expand/collapse logic + if (value) + ExpandCallCount++; + else + CollapseCallCount++; + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded))); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } + + // Simple target for IsExpanded property + private class TestExpandableTarget : AvaloniaObject + { + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + private TypedBinding? _typedBinding; + private TestExpandableNode? _sourceNode; + + public void Bind( + StyledProperty property, + IObservable> source, + TypedBinding typedBinding, + TestExpandableNode sourceNode + ) + { + this.Bind(property, source, BindingPriority.Style); + _sourceNode = sourceNode; + _typedBinding = typedBinding; + } + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set + { + SetValue(IsExpandedProperty, value); + _typedBinding?.Write?.Invoke(_sourceNode!, value); + } + } + } + + // Test view model implementing property change notifications + private class TestViewModel : INotifyPropertyChanged + { + private string _name = string.Empty; + private TestViewModel? _child = null; + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public TestViewModel? Child + { + get => _child; + set => SetProperty(ref _child, value); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected void SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (!EqualityComparer.Default.Equals(field, value)) + { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + } + + // Simple target class with an AvaloniaProperty + private class TestTarget : AvaloniaObject + { + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + } +} \ No newline at end of file