From 51ac9aa557ba62753c7a64e97b5dc214c01b1c7a Mon Sep 17 00:00:00 2001 From: startewho Date: Mon, 18 Mar 2024 11:16:56 +0800 Subject: [PATCH 01/36] fix the empty cell value --- .../Models/TreeDataGrid/TextCell.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs index 13e16b4d..fe721f5b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs @@ -49,7 +49,16 @@ public TextCell( public string? Text { get => _value?.ToString(); - set => Value = (T?)Convert.ChangeType(value, typeof(T)); + set{ + if (string.IsNullOrEmpty(value)) + { + Value = default(T?); + } + else + { + Value = (T?)Convert.ChangeType(value, typeof(T)); + } + } } public T? Value From bfc80e2873eb201326ee7311f9334833ac2303e7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 21 Mar 2024 22:23:39 +0100 Subject: [PATCH 02/36] Add setters to a couple of interface members. To allow easier switching between types of source. --- src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs index 562a1c1c..dd2c48ac 100644 --- a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs @@ -23,9 +23,9 @@ public interface ITreeDataGridSource : INotifyPropertyChanged IRows Rows { get; } /// - /// Gets the selection model. + /// Gets or sets the selection model. /// - ITreeDataGridSelection? Selection { get; } + ITreeDataGridSelection? Selection { get; set; } /// /// Gets a value indicating whether the data source is hierarchical. @@ -84,8 +84,8 @@ void DragDropRows( public interface ITreeDataGridSource : ITreeDataGridSource { /// - /// Gets the items in the data source. + /// Gets or sets the items in the data source. /// - new IEnumerable Items { get; } + new IEnumerable Items { get; set; } } } From fa7aca2ebae15dd3220fe94abb879208bcb932c8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 21 Mar 2024 22:24:05 +0100 Subject: [PATCH 03/36] Add example of switching layout from tree to flat. --- samples/TreeDataGridDemo/MainWindow.axaml | 5 + .../ViewModels/FilesPageViewModel.cs | 192 +++++++++++++----- 2 files changed, 142 insertions(+), 55 deletions(-) diff --git a/samples/TreeDataGridDemo/MainWindow.axaml b/samples/TreeDataGridDemo/MainWindow.axaml index 2a1ba4c5..c3f192f7 100644 --- a/samples/TreeDataGridDemo/MainWindow.axaml +++ b/samples/TreeDataGridDemo/MainWindow.axaml @@ -65,6 +65,11 @@ DockPanel.Dock="Right"> Cell Selection + + Flat + ? _treeSource; + private FlatTreeDataGridSource? _flatSource; + private ITreeDataGridSource _source; private bool _cellSelection; private FileTreeNodeModel? _root; private string _selectedDrive; @@ -38,61 +40,17 @@ public FilesPageViewModel() _selectedDrive = Drives.FirstOrDefault() ?? "/"; } - Source = new HierarchicalTreeDataGridSource(Array.Empty()) - { - Columns = - { - new CheckBoxColumn( - null, - x => x.IsChecked, - (o, v) => o.IsChecked = v, - options: new() - { - CanUserResizeColumn = false, - }), - new HierarchicalExpanderColumn( - new TemplateColumn( - "Name", - "FileNameCell", - "FileNameEditCell", - new GridLength(1, GridUnitType.Star), - new() - { - CompareAscending = FileTreeNodeModel.SortAscending(x => x.Name), - CompareDescending = FileTreeNodeModel.SortDescending(x => x.Name), - IsTextSearchEnabled = true, - TextSearchValueSelector = x => x.Name - }), - x => x.Children, - x => x.HasChildren, - x => x.IsExpanded), - new TextColumn( - "Size", - x => x.Size, - options: new() - { - CompareAscending = FileTreeNodeModel.SortAscending(x => x.Size), - CompareDescending = FileTreeNodeModel.SortDescending(x => x.Size), - }), - new TextColumn( - "Modified", - x => x.Modified, - options: new() - { - CompareAscending = FileTreeNodeModel.SortAscending(x => x.Modified), - CompareDescending = FileTreeNodeModel.SortDescending(x => x.Modified), - }), - } - }; - - Source.RowSelection!.SingleSelect = false; - Source.RowSelection.SelectionChanged += SelectionChanged; + _source = _treeSource = CreateTreeSource(); this.WhenAnyValue(x => x.SelectedDrive) .Subscribe(x => { _root = new FileTreeNodeModel(_selectedDrive, isDirectory: true, isRoot: true); - Source.Items = new[] { _root }; + + if (_treeSource is not null) + _treeSource.Items = new[] { _root }; + else if (_flatSource is not null) + _flatSource.Items = _root.Children; }); } @@ -115,6 +73,16 @@ public bool CellSelection public IList Drives { get; } + public bool FlatList + { + get => Source != _treeSource; + set + { + if (value != FlatList) + Source = value ? _flatSource ??= CreateFlatSource() : _treeSource!; + } + } + public string SelectedDrive { get => _selectedDrive; @@ -127,7 +95,11 @@ public string? SelectedPath set => SetSelectedPath(value); } - public HierarchicalTreeDataGridSource Source { get; } + public ITreeDataGridSource Source + { + get => _source; + private set => this.RaiseAndSetIfChanged(ref _source, value); + } public static IMultiValueConverter FileIconConverter { @@ -151,11 +123,115 @@ public static IMultiValueConverter FileIconConverter } } + private FlatTreeDataGridSource CreateFlatSource() + { + var result = new FlatTreeDataGridSource(_root!.Children) + { + Columns = + { + new CheckBoxColumn( + null, + x => x.IsChecked, + (o, v) => o.IsChecked = v, + options: new() + { + CanUserResizeColumn = false, + }), + new TemplateColumn( + "Name", + "FileNameCell", + "FileNameEditCell", + new GridLength(1, GridUnitType.Star), + new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Name), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Name), + IsTextSearchEnabled = true, + TextSearchValueSelector = x => x.Name + }), + new TextColumn( + "Size", + x => x.Size, + options: new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Size), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Size), + }), + new TextColumn( + "Modified", + x => x.Modified, + options: new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Modified), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Modified), + }), + } + }; + + result.RowSelection!.SingleSelect = false; + result.RowSelection.SelectionChanged += SelectionChanged; + return result; + } + + private HierarchicalTreeDataGridSource CreateTreeSource() + { + var result = new HierarchicalTreeDataGridSource(Array.Empty()) + { + Columns = + { + new CheckBoxColumn( + null, + x => x.IsChecked, + (o, v) => o.IsChecked = v, + options: new() + { + CanUserResizeColumn = false, + }), + new HierarchicalExpanderColumn( + new TemplateColumn( + "Name", + "FileNameCell", + "FileNameEditCell", + new GridLength(1, GridUnitType.Star), + new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Name), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Name), + IsTextSearchEnabled = true, + TextSearchValueSelector = x => x.Name + }), + x => x.Children, + x => x.HasChildren, + x => x.IsExpanded), + new TextColumn( + "Size", + x => x.Size, + options: new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Size), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Size), + }), + new TextColumn( + "Modified", + x => x.Modified, + options: new() + { + CompareAscending = FileTreeNodeModel.SortAscending(x => x.Modified), + CompareDescending = FileTreeNodeModel.SortDescending(x => x.Modified), + }), + } + }; + + result.RowSelection!.SingleSelect = false; + result.RowSelection.SelectionChanged += SelectionChanged; + return result; + } + private void SetSelectedPath(string? value) { if (string.IsNullOrEmpty(value)) { - Source.RowSelection!.Clear(); + GetRowSelection(Source).Clear(); return; } @@ -204,12 +280,18 @@ private void SetSelectedPath(string? value) } } - Source.RowSelection!.SelectedIndex = index; + GetRowSelection(Source).SelectedIndex = index; + } + + private ITreeDataGridRowSelectionModel GetRowSelection(ITreeDataGridSource source) + { + return source.Selection as ITreeDataGridRowSelectionModel ?? + throw new InvalidOperationException("Expected a row selection model."); } private void SelectionChanged(object? sender, TreeSelectionModelSelectionChangedEventArgs e) { - var selectedPath = Source.RowSelection?.SelectedItem?.Path; + var selectedPath = GetRowSelection(Source).SelectedItem?.Path; this.RaiseAndSetIfChanged(ref _selectedPath, selectedPath, nameof(SelectedPath)); foreach (var i in e.DeselectedItems) From bf60cccecd188c853d739db2340b6f7ea55006cd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Mar 2024 10:11:47 +0100 Subject: [PATCH 04/36] Hack fix for #15075. https://github.com/AvaloniaUI/Avalonia/issues/15075 breaks a test I'm currently writing. --- .../Primitives/TreeDataGridPresenterBase.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 4d133190..5f4e4850 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -427,7 +427,7 @@ protected virtual void OnEffectiveViewportChanged(object? sender, EffectiveViewp // viewport. Viewport = e.EffectiveViewport.Size == default ? s_invalidViewport : - e.EffectiveViewport.Intersect(new(Bounds.Size)); + Intersect(e.EffectiveViewport, new(Bounds.Size)); _isWaitingForViewportUpdate = false; @@ -730,6 +730,24 @@ private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs private static bool HasInfinity(Size s) => double.IsInfinity(s.Width) || double.IsInfinity(s.Height); + private static Rect Intersect(Rect a, Rect b) + { + // Hack fix for https://github.com/AvaloniaUI/Avalonia/issues/15075 + var newLeft = (a.X > b.X) ? a.X : b.X; + var newTop = (a.Y > b.Y) ? a.Y : b.Y; + var newRight = (a.Right < b.Right) ? a.Right : b.Right; + var newBottom = (a.Bottom < b.Bottom) ? a.Bottom : b.Bottom; + + if ((newRight >= newLeft) && (newBottom >= newTop)) + { + return new Rect(newLeft, newTop, newRight - newLeft, newBottom - newTop); + } + else + { + return default; + } + } + private struct MeasureViewport { public int anchorIndex; From 356db007f15147857b9bd20236942cdb1ae578bc Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Mar 2024 10:12:13 +0100 Subject: [PATCH 05/36] Add failing test for reassigning TDG source. Columns are initialized sized incorrectly. --- .../TreeDataGridTests_Flat.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs index 537cd7a8..43c19570 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs @@ -312,6 +312,46 @@ public void Does_Not_Realize_Columns_Outside_Viewport() Assert.True(double.IsNaN(columns[3].ActualWidth)); } + [AvaloniaFact(Timeout = 10000)] + public void Columns_Are_Correctly_Sized_After_Changing_Source() + { + // Create the initial target with 2 columns and make sure our preconditions are correct. + var (target, items) = CreateTarget(columns: new IColumn[] + { + new TextColumn("ID", x => x.Id, width: new GridLength(1, GridUnitType.Star)), + new TextColumn("Title1", x => x.Title, options: MinWidth(50)), + }); + + AssertColumnIndexes(target, 0, 2); + + // Create a new source and assign it to the TreeDataGrid. + var newSource = new FlatTreeDataGridSource(items) + { + Columns = + { + new TextColumn("ID", x => x.Id, width: new GridLength(1, GridUnitType.Star)), + new TextColumn("Title1", x => x.Title, options: MinWidth(20)), + new TextColumn("Title2", x => x.Title, options: MinWidth(20)), + } + }; + + target.Source = newSource; + + // The columns should not have an ActualWidth yet. + Assert.True(double.IsNaN(newSource.Columns[0].ActualWidth)); + Assert.True(double.IsNaN(newSource.Columns[1].ActualWidth)); + Assert.True(double.IsNaN(newSource.Columns[2].ActualWidth)); + + // Do a layout pass and check that the columns have been correctly sized. + target.UpdateLayout(); + AssertColumnIndexes(target, 0, 3); + + var columns = (ColumnList)target.Columns!; + Assert.Equal(60, columns[0].ActualWidth); + Assert.Equal(20, columns[1].ActualWidth); + Assert.Equal(20, columns[2].ActualWidth); + } + public class RemoveItems { [AvaloniaFact(Timeout = 10000)] From a7e4796c024554ee6b866fb4233aa84f552f465c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 22 Mar 2024 11:18:44 +0100 Subject: [PATCH 06/36] Fix invalid column sizes after assigning new source. Don't call `UpdateColumnSizes` when the viewport changes if no measure pass has been carried out yet. --- .../Models/TreeDataGrid/ColumnList.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs index ae10499d..6393108c 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs @@ -9,6 +9,7 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// public class ColumnList : NotifyingListBase>, IColumns { + private bool _initialized; private double _viewportWidth; public event EventHandler? LayoutInvalidated; @@ -22,6 +23,7 @@ public void AddRange(IEnumerable> items) public Size CellMeasured(int columnIndex, int rowIndex, Size size) { var column = (IUpdateColumnLayout)this[columnIndex]; + _initialized = true; return new Size(column.CellMeasured(size.Width, rowIndex), size.Height); } @@ -103,7 +105,8 @@ public void ViewportChanged(Rect viewport) if (_viewportWidth != viewport.Width) { _viewportWidth = viewport.Width; - UpdateColumnSizes(); + if (_initialized) + UpdateColumnSizes(); } } From 061d64bda51c40e98c3350282c72ebe420d4a9f5 Mon Sep 17 00:00:00 2001 From: Dev AM <125025268+CodeDevAM@users.noreply.github.com> Date: Tue, 21 May 2024 15:06:37 +0200 Subject: [PATCH 07/36] Allow internal properties in expressions Allows to use public and internal properties to be used in expressions used for bindings. --- .../Experimental/Data/TypedBinding`1.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs index c7c4afb3..b66f4b0d 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs @@ -45,14 +45,16 @@ public static TypedBinding TwoWay(Expression> e $"Cannot create a two-way binding for '{expression}' because the expression does not target a property.", nameof(expression)); - if (property.GetGetMethod() is null) + MethodInfo? getMethodInfo = property.GetGetMethod(true); + if (getMethodInfo is null || getMethodInfo.IsPrivate) throw new ArgumentException( - $"Cannot create a two-way binding for '{expression}' because the property has no getter.", + $"Cannot create a two-way binding for '{expression}' because the property has no getter or the getter is private.", nameof(expression)); - if (property.GetSetMethod() is null) + MethodInfo? setMethodInfo = property.GetSetMethod(true); + if (setMethodInfo is null || setMethodInfo.IsPrivate) throw new ArgumentException( - $"Cannot create a two-way binding for '{expression}' because the property has no setter.", + $"Cannot create a two-way binding for '{expression}' because the property has no setter or the setter is private.", nameof(expression)); // TODO: This is using reflection and mostly untested. Unit test it properly and From fb7cd39e9dc24a5708bf3d736eb9fb5bab030d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 6 Jun 2024 11:22:16 +0200 Subject: [PATCH 08/36] Ignore control characters on text input --- src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index 2017e707..dc9fe98b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -368,6 +368,10 @@ protected override void OnKeyUp(KeyEventArgs e) protected override void OnTextInput(TextInputEventArgs e) { base.OnTextInput(e); + + if (e.Text is { Length: > 0 } && char.IsControl(e.Text[0])) + return; + _selection?.OnTextInput(this, e); } From 62211e5f63afbd51228aac64e0f1c49ab91beea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 6 Jun 2024 11:55:43 +0200 Subject: [PATCH 09/36] Use StringComparison.Ordinal for the comparison Instead of filtering the control characters --- .../Selection/TreeDataGridRowSelectionModel.cs | 2 +- src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs index ec112459..f8b107ba 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs @@ -302,7 +302,7 @@ private bool SearchAndSelectRow(TreeDataGrid treeDataGrid, if (valueSelector != null && model != null) { var value = valueSelector(model); - if (value != null && value.ToUpper().StartsWith(candidatePattern)) + if (value != null && value.ToUpper().StartsWith(candidatePattern, StringComparison.Ordinal)) { UpdateSelection(treeDataGrid, newIndex, true); treeDataGrid.RowsPresenter?.BringIntoView(newIndex); diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index dc9fe98b..2017e707 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -368,10 +368,6 @@ protected override void OnKeyUp(KeyEventArgs e) protected override void OnTextInput(TextInputEventArgs e) { base.OnTextInput(e); - - if (e.Text is { Length: > 0 } && char.IsControl(e.Text[0])) - return; - _selection?.OnTextInput(this, e); } From 83ea1a89a52cb0f416d786ebb0b0dda24684e55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 6 Jun 2024 12:05:06 +0200 Subject: [PATCH 10/36] Revert "Use StringComparison.Ordinal for the comparison" This reverts commit 62211e5f63afbd51228aac64e0f1c49ab91beea9. --- .../Selection/TreeDataGridRowSelectionModel.cs | 2 +- src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs index f8b107ba..ec112459 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs @@ -302,7 +302,7 @@ private bool SearchAndSelectRow(TreeDataGrid treeDataGrid, if (valueSelector != null && model != null) { var value = valueSelector(model); - if (value != null && value.ToUpper().StartsWith(candidatePattern, StringComparison.Ordinal)) + if (value != null && value.ToUpper().StartsWith(candidatePattern)) { UpdateSelection(treeDataGrid, newIndex, true); treeDataGrid.RowsPresenter?.BringIntoView(newIndex); diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index 2017e707..dc9fe98b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -368,6 +368,10 @@ protected override void OnKeyUp(KeyEventArgs e) protected override void OnTextInput(TextInputEventArgs e) { base.OnTextInput(e); + + if (e.Text is { Length: > 0 } && char.IsControl(e.Text[0])) + return; + _selection?.OnTextInput(this, e); } From 7ff0fe367e188ab73467cbfc12a1e11cdd7e71fb Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 2 Jul 2024 15:21:01 +0200 Subject: [PATCH 11/36] Try to avoid NRE in TreeSelectedItemsBase. --- .../Selection/TreeSelectedItems.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectedItems.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectedItems.cs index 19494388..ee25a7b3 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectedItems.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectedItems.cs @@ -56,7 +56,8 @@ public T? this[int index] { for (var i = range.Begin; i <= range.End; ++i) { - yield return node.ItemsView![i]; + if (node.ItemsView is not null) + yield return node.ItemsView[i]; } } @@ -110,4 +111,4 @@ public TreeSelectedItems(TreeSelectionModelBase root) : base(root) { } yield return i; } } -} \ No newline at end of file +} From 45fa70bc2f95bdd34118944331c3db0425e1b536 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Sep 2024 09:34:39 +0200 Subject: [PATCH 12/36] Use consistent seed for bogus. Makes it easier to debug if the data is the same each time. --- samples/TreeDataGridDemo/App.axaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/TreeDataGridDemo/App.axaml.cs b/samples/TreeDataGridDemo/App.axaml.cs index 9edeec1e..cc3d1f15 100644 --- a/samples/TreeDataGridDemo/App.axaml.cs +++ b/samples/TreeDataGridDemo/App.axaml.cs @@ -9,6 +9,7 @@ public class App : Application public override void Initialize() { AvaloniaXamlLoader.Load(this); + Bogus.Randomizer.Seed = new System.Random(0); } public override void OnFrameworkInitializationCompleted() From 018718d0ae74fd0c25e442c32de1345bfdd831c9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Sep 2024 11:31:11 +0200 Subject: [PATCH 13/36] Add failing tests. We're not correctly handling the focused item being removed while it's scrolled outside the viewport. --- .../TreeDataGridRowsPresenterTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Primitives/TreeDataGridRowsPresenterTests.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Primitives/TreeDataGridRowsPresenterTests.cs index b762dd26..d3822bd7 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Primitives/TreeDataGridRowsPresenterTests.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Primitives/TreeDataGridRowsPresenterTests.cs @@ -352,6 +352,78 @@ public void Handles_Removing_Row_Range_That_Invalidates_Current_Viewport() Assert.Equal(new Size(100, 100), target.Bounds.Size); } + [AvaloniaFact(Timeout = 10000)] + public void Handles_Removing_Focused_Row_While_Outside_Viewport() + { + var (target, scroll, items) = CreateTarget(); + var element = target.RealizedElements.ElementAt(0)!; + + element.Focusable = true; + element.Focus(); + + // Scroll down one item. + scroll.Offset = new Vector(0, 10); + Layout(target); + + // Remove the focused element. + items.RemoveAt(0); + + // Scroll back to the beginning. + scroll.Offset = new Vector(0, 0); + Layout(target); + + // The correct element should be shown. + Assert.Same(items[0], target.RealizedElements.ElementAt(0)!.DataContext); + } + + [AvaloniaFact(Timeout = 10000)] + public void Handles_Replacing_Focused_Row_While_Outside_Viewport() + { + var (target, scroll, items) = CreateTarget(); + var element = target.RealizedElements.ElementAt(0)!; + + element.Focusable = true; + element.Focus(); + + // Scroll down one item. + scroll.Offset = new Vector(0, 10); + Layout(target); + + // Replace the focused element. + items[0] = new Model { Id = 100, Title = "New Item" }; + + // Scroll back to the beginning. + scroll.Offset = new Vector(0, 0); + Layout(target); + + // The correct element should be shown. + Assert.Same(items[0], target.RealizedElements.ElementAt(0)!.DataContext); + } + + [AvaloniaFact(Timeout = 10000)] + public void Handles_Moving_Focused_Row_While_Outside_Viewport() + { + var (target, scroll, items) = CreateTarget(); + var element = target.RealizedElements.ElementAt(0)!; + + element.Focusable = true; + element.Focus(); + + // Scroll down one item. + scroll.Offset = new Vector(0, 10); + Layout(target); + + // Move the focused element. + items.Move(0, items.Count - 1); + + // Scroll back to the beginning. + scroll.Offset = new Vector(0, 0); + Layout(target); + + // The correct element should be shown. + Assert.Same(items[0], target.RealizedElements.ElementAt(0)!.DataContext); + } + [AvaloniaFact(Timeout = 10000)] public void Updates_Star_Column_ActualWidth() { From e7f8943d9769ac38f14f83d30a24a390b44d3c50 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Sep 2024 11:31:33 +0200 Subject: [PATCH 14/36] Handle removing focused item. Handle the focused item being removed while it's scrolled outside the viewport. --- .../Primitives/TreeDataGridPresenterBase.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 5f4e4850..c2c64081 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -663,6 +663,13 @@ private void RecycleElement(Control element, int index) private void RecycleElementOnItemRemoved(Control element) { + if (element == _focusedElement) + { + _focusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus; + _focusedElement = null; + _focusedIndex = -1; + } + UnrealizeElementOnItemRemoved(element); element.IsVisible = false; ElementFactory!.RecycleElement(element); @@ -691,6 +698,12 @@ private void TrimUnrealizedChildren() private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + void ClearFocusedElement(int index, int count) + { + if (_focusedElement is not null && _focusedIndex >= index && _focusedIndex < index + count) + RecycleElementOnItemRemoved(_focusedElement); + } + InvalidateMeasure(); if (_realizedElements is null) @@ -703,13 +716,16 @@ private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEve break; case NotifyCollectionChangedAction.Remove: _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); + ClearFocusedElement(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Replace: _realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved); + ClearFocusedElement(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Move: _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); _realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex); + ClearFocusedElement(e.OldStartingIndex, e.OldItems!.Count); break; case NotifyCollectionChangedAction.Reset: _realizedElements.ItemsReset(_recycleElementOnItemRemoved); From a4bacaece5742ef1646ccb6ebc16308cb3c89deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 10 Sep 2024 13:46:26 +0200 Subject: [PATCH 15/36] Recycle the focused element on NotifyCollectionChangedAction.Reset --- .../Primitives/TreeDataGridPresenterBase.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index c2c64081..483acddb 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -729,6 +729,8 @@ void ClearFocusedElement(int index, int count) break; case NotifyCollectionChangedAction.Reset: _realizedElements.ItemsReset(_recycleElementOnItemRemoved); + if (_focusedElement is not null ) + RecycleElementOnItemRemoved(_focusedElement); break; } } From e475438ad2bfd10397160e04d01be41b04da508c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 12 Sep 2024 14:51:03 +0200 Subject: [PATCH 16/36] Reset mouse down location... .. on release or capture lost. Fixes an issue with phantom drags occurring on macOS due to https://github.com/AvaloniaUI/Avalonia/issues/16936 --- .../Primitives/TreeDataGridRow.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs index bd6c58d5..7f345e48 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs @@ -167,6 +167,18 @@ protected override void OnPointerMoved(PointerEventArgs e) owner?.RaiseRowDragStarted(e); } + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _mouseDownPosition = s_InvalidPoint; + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _mouseDownPosition = s_InvalidPoint; + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { if (change.Property == IsSelectedProperty) From c45175f36f12721391c278dcaabd59b31ad76dc8 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Thu, 10 Oct 2024 23:04:57 +0100 Subject: [PATCH 17/36] dont throw an exception in HierachicalTreeDataGridSource when columns are re-ordered. this was preventing users from changing the column order at runtime. --- .../HierarchicalTreeDataGridSource.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs index 75363615..b87d9b38 100644 --- a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs @@ -358,8 +358,6 @@ private void OnColumnsCollectionChanged(object? sender, NotifyCollectionChangedE } } break; - default: - throw new NotImplementedException(); } } } From 04f57cc63a470f71e26598c9b2f7eed59fcb8e30 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 11 Oct 2024 10:38:00 +0100 Subject: [PATCH 18/36] throw exceptions if the user tries to move, replace, remove or add a second expander column. --- .../HierarchicalTreeDataGridSource.cs | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs index b87d9b38..40ec77c4 100644 --- a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -346,18 +347,66 @@ private void OnColumnsCollectionChanged(object? sender, NotifyCollectionChangedE switch (e.Action) { case NotifyCollectionChangedAction.Add: - if (_expanderColumn is null && e.NewItems is object) + HandleAdd(e.NewItems); + break; + + case NotifyCollectionChangedAction.Remove: + HandleRemoveReplaceOrMove(e.OldItems, "removed"); + break; + + case NotifyCollectionChangedAction.Replace: + HandleRemoveReplaceOrMove(e.NewItems, "replaced"); + break; + + case NotifyCollectionChangedAction.Move: + HandleRemoveReplaceOrMove(e.NewItems, "moved"); + break; + + case NotifyCollectionChangedAction.Reset: + if (_expanderColumn is not null) { - foreach (var i in e.NewItems) + throw new InvalidOperationException("The expander column cannot be removed by a reset."); + } + + _expanderColumn = null; // Optionally clear the expander column + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + private void HandleAdd(IList? newItems) + { + if (newItems is not null) + { + foreach (var i in newItems) + { + if (i is IExpanderColumn expander) + { + if (_expanderColumn is not null) { - if (i is IExpanderColumn expander) - { - _expanderColumn = expander; - break; - } + throw new InvalidOperationException("Only one expander column is allowed."); } + + _expanderColumn = expander; + break; } - break; + } + } + } + + private void HandleRemoveReplaceOrMove(IList? items, string action) + { + if (items is not null) + { + foreach (var i in items) + { + if (i is IExpanderColumn && _expanderColumn is not null) + { + throw new InvalidOperationException($"The expander column cannot be {action}."); + } + } } } } From 0565c4301486b490ee131821d1ea3e5b53a7c98f Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 11 Oct 2024 10:46:40 +0100 Subject: [PATCH 19/36] Add tests for case where Expander column is removed or a second is added. --- .../HierarchicalTreeDataGridSource.cs | 2 -- .../HierarchicalTreeDataGridSourceTests.cs | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs index 40ec77c4..6689e5af 100644 --- a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs @@ -367,8 +367,6 @@ private void OnColumnsCollectionChanged(object? sender, NotifyCollectionChangedE { throw new InvalidOperationException("The expander column cannot be removed by a reset."); } - - _expanderColumn = null; // Optionally clear the expander column break; default: diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/HierarchicalTreeDataGridSourceTests.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/HierarchicalTreeDataGridSourceTests.cs index cbc15a0e..7b192a0a 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/HierarchicalTreeDataGridSourceTests.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/HierarchicalTreeDataGridSourceTests.cs @@ -491,6 +491,36 @@ public void CollapseAll_Collapses_All_Rows(bool sorted) } } + [AvaloniaFact(Timeout = 10000)] + public void Adding_Second_Expander_Column_Throws() + { + var data = CreateData(); + var target = CreateTarget(data, false); + + Assert.Throws(() => + { + target.Columns.Add(new HierarchicalExpanderColumn( + new TextColumn("ID", x => x.Id), + x => x.Children, + null, + x => x.IsExpanded)); + }); + } + + [AvaloniaFact(Timeout = 10000)] + public void Removing_Expander_Column_Throws() + { + var data = CreateData(); + var target = CreateTarget(data, false); + + var expander = target.Columns.OfType>().First(); + + Assert.Throws(() => + { + target.Columns.Remove(expander); + }); + } + public class ExpansionBinding { [AvaloniaFact(Timeout = 10000)] From fe1a1deccc3304ea05573d0f2ed3d4f5deb5bc8d Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 11 Oct 2024 12:06:38 +0100 Subject: [PATCH 20/36] add failing unit test to show that TreeDataColumnHeader.ColumnIndex is not kept up to date. --- .../TreeDataGridTests_Flat.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs index 43c19570..083396e7 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs @@ -312,6 +312,35 @@ public void Does_Not_Realize_Columns_Outside_Viewport() Assert.True(double.IsNaN(columns[3].ActualWidth)); } + [AvaloniaFact(Timeout = 10000)] + public void Header_Column_Indexes_Are_Updated_When_Columns_Are_Updated() + { + var (target, items) = CreateTarget(columns: new IColumn[] + { + new TextColumn("ID", x => x.Id, width: new GridLength(1, GridUnitType.Star)), + new TextColumn("Title1", x => x.Title, width: new GridLength(1, GridUnitType.Star)), + new TextColumn("Title2", x => x.Title, width: new GridLength(1, GridUnitType.Star)), + new TextColumn("Title3", x => x.Title, width: new GridLength(1, GridUnitType.Star)), + }); + + AssertColumnIndexes(target, 0, 4); + + var source =(FlatTreeDataGridSource)target.Source!; + + var movedColumn = source.Columns[1]; + source.Columns.Remove(movedColumn); + + AssertColumnIndexes(target, 0, 3); + + source.Columns.Add(movedColumn); + + var root = (TestWindow)target.GetVisualRoot()!; + root.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + AssertColumnIndexes(target, 0, 4); + } + [AvaloniaFact(Timeout = 10000)] public void Columns_Are_Correctly_Sized_After_Changing_Source() { From 8963022cb52d00e4746ed7c5e074464a4c6a2856 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 11 Oct 2024 12:08:10 +0100 Subject: [PATCH 21/36] fix: TreeDataGridColumnHeader.ColumnIndex is kept in sync when the column order is re-arranged. --- .../Primitives/TreeDataGridColumnHeader.cs | 5 +++++ .../Primitives/TreeDataGridColumnHeadersPresenter.cs | 1 + 2 files changed, 6 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 148c8f5e..8098a82a 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -65,6 +65,11 @@ public void Realize(IColumns columns, int columnIndex) newInpc.PropertyChanged += OnModelPropertyChanged; } + public void UpdateColumnIndex(int columnIndex) + { + ColumnIndex = columnIndex; + } + public void Unrealize() { if (_model is INotifyPropertyChanged oldInpc) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeadersPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeadersPresenter.cs index facb2bc9..085dcaba 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeadersPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeadersPresenter.cs @@ -33,6 +33,7 @@ protected override void RealizeElement(Control element, IColumn column, int inde protected override void UpdateElementIndex(Control element, int oldIndex, int newIndex) { + ((TreeDataGridColumnHeader)element).UpdateColumnIndex(newIndex); ChildIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, newIndex)); } From 4ffa527f5813e94f2d3260feaf6667770e7000a9 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Thu, 17 Oct 2024 16:30:44 +0100 Subject: [PATCH 22/36] make sure the parent container that a recycled element will be inserted into is re-measured. --- .../Primitives/TreeDataGridElementFactory.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridElementFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridElementFactory.cs index 2129e056..1e06ea35 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridElementFactory.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridElementFactory.cs @@ -22,6 +22,7 @@ public Control GetOrCreateElement(object? data, Control parent) if (e.Parent == parent) { + parent.InvalidateMeasure(); elements.RemoveAt(i); return e; } @@ -35,6 +36,7 @@ public Control GetOrCreateElement(object? data, Control parent) if (e.Parent is null || parentPanel is not null) { + parent.InvalidateMeasure(); parentPanel?.Children.Remove(e); Debug.Assert(e.Parent is null); elements.RemoveAt(i); From d198423ebe2fe1fbe83735ad05a620f13be65af4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 1 Nov 2024 10:20:50 +0100 Subject: [PATCH 23/36] Fix typo. --- .../Selection/TreeSelectionModelBaseTests_Multiple.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Multiple.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Multiple.cs index 382a6b0d..fd41310a 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Multiple.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Multiple.cs @@ -1517,7 +1517,7 @@ public void Clearing_Children_Updates_State() var target = CreateTarget(data); var selectionChangedRaised = 0; var sourceResetRaised = 0; - var indexesChangedraised = 0; + var indexesChangedRaised = 0; target.Select(new IndexPath(0, 1)); target.Select(new IndexPath(0, 1, 0)); @@ -1528,7 +1528,7 @@ public void Clearing_Children_Updates_State() target.SelectionChanged += (s, e) => ++selectionChangedRaised; target.SourceReset += (s, e) => ++sourceResetRaised; - target.IndexesChanged += (s, e) => ++indexesChangedraised; + target.IndexesChanged += (s, e) => ++indexesChangedRaised; data[0].Children!.Clear(); @@ -1538,7 +1538,7 @@ public void Clearing_Children_Updates_State() Assert.Equal("Node 1-3", target.SelectedItem!.Caption); Assert.Equal(new[] { "Node 1-3" }, target.SelectedItems.Select(x => x!.Caption)); Assert.Equal(new IndexPath(1, 3), target.AnchorIndex); - Assert.Equal(0, indexesChangedraised); + Assert.Equal(0, indexesChangedRaised); Assert.Equal(0, selectionChangedRaised); Assert.Equal(1, sourceResetRaised); } From 82743ced3f24ceef9cc98193564ceda8ec39ac4a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 1 Nov 2024 10:28:16 +0100 Subject: [PATCH 24/36] Re-enable disabled test. Seems to be a left-over from development. Clear the anchor index if it's invalid to make the test pass. --- .../Selection/TreeSelectionModelBase.cs | 7 +++++++ .../Selection/TreeSelectionModelBaseTests_Single.cs | 6 ++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs index d853d333..554f99b8 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs @@ -340,6 +340,13 @@ protected internal virtual void OnNodeCollectionReset(IndexPath parentIndex, int selectedIndexChanged = selectedItemChanged = true; } + // If the anchor index is invalid, clear it. + if (_anchorIndex != default && !TryGetItemAt(_anchorIndex, out _)) + { + _anchorIndex = default; + anchorIndexChanged = true; + } + Count -= removeCount; SourceReset?.Invoke(this, new TreeSelectionModelSourceResetEventArgs(parentIndex)); diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs index 6279bc35..7e37c1e7 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs @@ -1170,7 +1170,7 @@ public void Replacing_Selected_Child_Item_Updates_State() Assert.Equal(1, selectedIndexRaised); Assert.Equal(1, selectedItemRaised); } -#if false + [AvaloniaFact(Timeout = 10000)] public void Resetting_Root_Updates_State() { @@ -1178,7 +1178,6 @@ public void Resetting_Root_Updates_State() var target = CreateTarget(data); var selectionChangedRaised = 0; var selectedIndexRaised = 0; - var resetRaised = 0; target.Select(new IndexPath(1)); @@ -1200,10 +1199,9 @@ public void Resetting_Root_Updates_State() Assert.Empty(target.SelectedItems); Assert.Equal(default, target.AnchorIndex); Assert.Equal(0, selectionChangedRaised); - Assert.Equal(1, resetRaised); Assert.Equal(1, selectedIndexRaised); } -#endif + [AvaloniaFact(Timeout = 10000)] public void Handles_Selection_Made_In_CollectionChanged() { From 1f1808100e9092470959dc25536c028f93e6b5f3 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 1 Nov 2024 10:33:16 +0100 Subject: [PATCH 25/36] Use C# 12. --- Directory.Build.props | 2 +- .../Avalonia.Controls.TreeDataGrid.csproj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6508ac29..b52a8875 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ true nullable net6.0 - 9 + 12 false false diff --git a/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj b/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj index d67e9d68..e1ca2c40 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj +++ b/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj @@ -2,7 +2,6 @@ net5.0 True - 10 Avalonia.Controls From 39f10e539a8e16b358af94e9630b260339032b40 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 1 Nov 2024 16:28:21 +0100 Subject: [PATCH 26/36] Add StringFormat option to text cells. --- .../Models/TreeDataGrid/ITextCell.cs | 7 ++- .../Models/TreeDataGrid/ITextCellOptions.cs | 14 +++++- .../Models/TreeDataGrid/TextCell.cs | 47 ++++++++++++------- .../Models/TreeDataGrid/TextColumnOptions.cs | 14 +++++- .../Primitives/TreeDataGridCell.cs | 7 +++ .../Primitives/TreeDataGridTextCell.cs | 12 +++-- .../Models/TextCellTests.cs | 45 +++++++++++++++++- 7 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCell.cs index f13885f5..b47c78d0 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCell.cs @@ -1,4 +1,6 @@ -using Avalonia.Media; +using System.Globalization; + +using Avalonia.Media; namespace Avalonia.Controls.Models.TreeDataGrid { @@ -21,7 +23,8 @@ public interface ITextCell : ICell /// Gets the cell's text wrapping mode. /// TextWrapping TextWrapping { get; } - + + /// /// Gets the cell's text alignment mode. /// TextAlignment TextAlignment { get; } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCellOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCellOptions.cs index 363a39ed..8eef14af 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCellOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ITextCellOptions.cs @@ -1,9 +1,21 @@ -using Avalonia.Media; +using System.Globalization; + +using Avalonia.Media; namespace Avalonia.Controls.Models.TreeDataGrid { public interface ITextCellOptions : ICellOptions { + /// + /// Gets the format string to be used to format the cell value. + /// + string StringFormat { get; } + + /// + /// Gets the culture to be used in conjunction with . + /// + CultureInfo Culture { get; } + /// /// Gets the text trimming mode for the cell. /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs index fe721f5b..18afebe8 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextCell.cs @@ -1,7 +1,6 @@ using System; -using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Reactive.Subjects; using Avalonia.Data; using Avalonia.Media; @@ -11,11 +10,11 @@ namespace Avalonia.Controls.Models.TreeDataGrid public class TextCell : NotifyingBase, ITextCell, IDisposable, IEditableObject { private readonly ISubject>? _binding; + private readonly ITextCellOptions? _options; private readonly IDisposable? _subscription; - [AllowNull] private T? _value; - [AllowNull] private T? _cancelValue; + private string? _editText; + private T? _value; private bool _isEditing; - private ITextCellOptions? _options; public TextCell(T? value) { @@ -48,15 +47,31 @@ public TextCell( public string? Text { - get => _value?.ToString(); - set{ - if (string.IsNullOrEmpty(value)) + get + { + if (_isEditing) + return _editText; + else if (_options?.StringFormat is { } format) + return string.Format(_options.Culture ?? CultureInfo.CurrentCulture, format, _value); + else + return _value?.ToString(); + } + set + { + if (_isEditing) { - Value = default(T?); + _editText = value; } else { - Value = (T?)Convert.ChangeType(value, typeof(T)); + try + { + Value = (T?)Convert.ChangeType(value, typeof(T)); + } + catch + { + // TODO: Data validation errors. + } } } } @@ -78,7 +93,7 @@ public void BeginEdit() if (!_isEditing && !IsReadOnly) { _isEditing = true; - _cancelValue = Value; + _editText = Text; } } @@ -86,19 +101,19 @@ public void CancelEdit() { if (_isEditing) { - Value = _cancelValue; _isEditing = false; - _cancelValue = default; + _editText = null; } } public void EndEdit() { - if (_isEditing && !EqualityComparer.Default.Equals(_value, _cancelValue)) + if (_isEditing) { + var text = _editText; _isEditing = false; - _cancelValue = default; - _binding!.OnNext(_value!); + _editText = null; + Text = text; } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index 17ce4941..485417d3 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -1,4 +1,6 @@ -using Avalonia.Media; +using System.Globalization; + +using Avalonia.Media; namespace Avalonia.Controls.Models.TreeDataGrid { @@ -13,6 +15,16 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// public bool IsTextSearchEnabled { get; set; } + /// + /// Gets or sets the format string for the cells in the column. + /// + public string StringFormat { get; set; } = "{0}"; + + /// + /// Culture info used in conjunction with + /// + public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + /// /// Gets or sets the text trimming mode for the cells in the column. /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs index dccb2b66..5a9822c4 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs @@ -93,7 +93,10 @@ protected void CancelEdit() protected void EndEdit() { if (EndEditCore() && Model is IEditableObject editable) + { editable.EndEdit(); + UpdateValue(); + } } protected void SubscribeToModelChanges() @@ -108,6 +111,10 @@ protected void UnsubscribeFromModelChanges() inpc.PropertyChanged -= OnModelPropertyChanged; } + protected virtual void UpdateValue() + { + } + protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) { _treeDataGrid = this.FindLogicalAncestorOfType(); diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs index 90a74a61..b56d09f5 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs @@ -1,8 +1,8 @@ using System.ComponentModel; +using System.Globalization; +using System.Reflection; using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Selection; -using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.Media; namespace Avalonia.Controls.Primitives @@ -64,6 +64,7 @@ public TextAlignment TextAlignment get => _textAlignment; set => SetAndRaise(TextAlignmentProperty, ref _textAlignment, value); } + public override void Realize( TreeDataGridElementFactory factory, ITreeDataGridSelectionInteraction? selection, @@ -71,7 +72,7 @@ public override void Realize( int columnIndex, int rowIndex) { - Value = model.Value?.ToString(); + Value = (model as ITextCell)?.Text; TextTrimming = (model as ITextCell)?.TextTrimming ?? TextTrimming.CharacterEllipsis; TextWrapping = (model as ITextCell)?.TextWrapping ?? TextWrapping.NoWrap; TextAlignment = (model as ITextCell)?.TextAlignment ?? TextAlignment.Left; @@ -85,6 +86,11 @@ public override void Unrealize() base.Unrealize(); } + protected override void UpdateValue() + { + Value = (Model as ITextCell)?.Text; + } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Models/TextCellTests.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Models/TextCellTests.cs index 44931703..66d2c2d3 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Models/TextCellTests.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Models/TextCellTests.cs @@ -63,7 +63,7 @@ public void Modified_Value_Is_Written_To_Binding_On_EndEdit() target.Text = "new"; Assert.Equal("new", target.Text); - Assert.Equal("new", target.Value); + Assert.Equal("initial", target.Value); Assert.Equal(new[] { "initial"}, result); target.EndEdit(); @@ -86,7 +86,7 @@ public void Modified_Value_Is_Not_Written_To_Binding_On_CancelEdit() target.Text = "new"; Assert.Equal("new", target.Text); - Assert.Equal("new", target.Value); + Assert.Equal("initial", target.Value); Assert.Equal(new[] { "initial" }, result); target.CancelEdit(); @@ -95,5 +95,46 @@ public void Modified_Value_Is_Not_Written_To_Binding_On_CancelEdit() Assert.Equal("initial", target.Value); Assert.Equal(new[] { "initial" }, result); } + + public class StringFormat + { + [AvaloniaFact(Timeout = 10000)] + public void Initial_Int_Value_Is_Formatted() + { + var binding = new BehaviorSubject>(42); + var target = new TextCell(binding, true, GetOptions()); + + Assert.Equal("42.00", target.Text); + Assert.Equal(42, target.Value); + } + + [AvaloniaFact(Timeout = 10000)] + public void Int_Value_Is_Formatted_After_Editing() + { + var binding = new BehaviorSubject>(42); + var target = new TextCell(binding, false, GetOptions()); + var result = new List(); + + binding.Subscribe(x => result.Add(x.Value)); + + target.BeginEdit(); + target.Text = "43"; + + Assert.Equal("43", target.Text); + Assert.Equal(42, target.Value); + Assert.Equal(new[] { 42 }, result); + + target.EndEdit(); + + Assert.Equal("43.00", target.Text); + Assert.Equal(43, target.Value); + Assert.Equal(new[] { 42, 43 }, result); + } + + private ITextCellOptions? GetOptions(string format = "{0:n2}") + { + return new TextColumnOptions { StringFormat = format }; + } + } } } From cd252c0a0cc396cff5d0c0e5526609cf41f92b9a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 20 Nov 2024 14:45:59 +0100 Subject: [PATCH 27/36] Don't write value to model when change comes from model. If a changed value comes from the model, then we shouldn't be writing it back to the model. Usually this would be a no-op but when a `StringFormat` is applied it causes the formatted value to be sent back to the underlying data model, and if the `StringFormat` is not round-trippable causes an exception. --- .../Primitives/TreeDataGridTextCell.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs index b56d09f5..510b35fe 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs @@ -33,6 +33,7 @@ public class TreeDataGridTextCell : TreeDataGridCell private string? _value; private TextBox? _edit; + private bool _modelValueChanging; private TextTrimming _textTrimming = TextTrimming.CharacterEllipsis; private TextWrapping _textWrapping = TextWrapping.NoWrap; private TextAlignment _textAlignment = TextAlignment.Left; @@ -54,7 +55,7 @@ public string? Value get => _value; set { - if (SetAndRaise(ValueProperty, ref _value, value) && Model is ITextCell cell) + if (SetAndRaise(ValueProperty, ref _value, value) && Model is ITextCell cell && !_modelValueChanging) cell.Text = _value; } } @@ -109,7 +110,17 @@ protected override void OnModelPropertyChanged(object? sender, PropertyChangedEv base.OnModelPropertyChanged(sender, e); if (e.PropertyName == nameof(ITextCell.Value)) - Value = Model?.Value?.ToString(); + { + try + { + _modelValueChanging = true; + Value = Model?.Value?.ToString(); + } + finally + { + _modelValueChanging = false; + } + } } } } From b3eff95cd6d31d233542f96a42e888efc71bd6b0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 25 Nov 2024 10:54:18 +0100 Subject: [PATCH 28/36] Handle collection Move in TreeSelectionNode. --- .../Selection/IndexRange.cs | 4 +- .../Selection/SelectionNodeBase.cs | 56 ++++- .../Selection/TreeSelectionModelBase.cs | 9 +- ...eeSelectionModelIndexesChangedEventArgs.cs | 26 ++- .../Selection/TreeSelectionNode.cs | 40 ++-- .../TreeSelectionModelBaseTests_Single.cs | 201 ++++++++++++++++++ 6 files changed, 313 insertions(+), 23 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRange.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRange.cs index 59d313e8..f886d027 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRange.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRange.cs @@ -275,13 +275,13 @@ public static int Remove( public static int Remove( IList destination, IReadOnlyList source, - IList? added = null) + IList? removed = null) { var result = 0; foreach (var range in source) { - result += Remove(destination, range, added); + result += Remove(destination, range, removed); } return result; diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs index a97f11e9..97959b94 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs @@ -2,6 +2,8 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.Reflection; +using System.Threading.Channels; using Avalonia.Controls.Utils; #nullable enable @@ -243,7 +245,7 @@ protected int CommitDeselect(int begin, int end) /// assigning new indexes. Override this method to carry out additional computation when /// items are added. /// - protected virtual CollectionChangeState OnItemsAdded(int index, IList items) + private protected CollectionChangeState OnItemsAdded(int index, IList items) { var count = items.Count; var shifted = false; @@ -305,7 +307,7 @@ protected virtual CollectionChangeState OnItemsAdded(int index, IList items) /// assigning new indexes. Override this method to carry out additional computation when /// items are removed. /// - private protected virtual CollectionChangeState OnItemsRemoved(int index, IList items) + private protected CollectionChangeState OnItemsRemoved(int index, IList items) { var count = items.Count; var removedRange = new IndexRange(index, index + count - 1); @@ -349,10 +351,58 @@ private protected virtual CollectionChangeState OnItemsRemoved(int index, IList }; } + private protected IReadOnlyList OnItemsMoved( + int oldIndex, + int newIndex, + int count) + { + var selectedItemsMoved = false; + var unselectedItemsMoved = false; + + if (_ranges is not null) + { + var removedRange = new IndexRange(oldIndex, oldIndex + count - 1); + var movedRanges = new List(); + + if (IndexRange.Remove(_ranges, removedRange, movedRanges) > 0) + { + foreach (var range in movedRanges) + { + var insertRange = new IndexRange( + range.Begin + (newIndex - oldIndex), + range.End + (newIndex - oldIndex)); + IndexRange.Add(_ranges, insertRange); + selectedItemsMoved = true; + } + } + + for (var i = 0; i < Ranges!.Count; ++i) + { + var existing = Ranges[i]; + + if (existing.Begin >= oldIndex && existing.End < newIndex) + { + _ranges[i] = new IndexRange(existing.Begin - count, existing.End - count); + unselectedItemsMoved = true; + } + } + } + + if (selectedItemsMoved || unselectedItemsMoved) + { + var changes = new List(); + return changes; + } + else + { + return []; + } + } + /// /// Details the results of a collection change on the current selection; /// - protected class CollectionChangeState + private protected class CollectionChangeState { /// /// Gets or sets the first index that was shifted as a result of the collection diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs index 554f99b8..e3525f64 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelBase.cs @@ -271,7 +271,8 @@ internal void OnNodeCollectionChangeStarted() internal void OnNodeCollectionChanged( IndexPath parentIndex, - int shiftIndex, + int shiftStartIndex, + int shiftEndIndex, int shiftDelta, bool raiseIndexesChanged, IReadOnlyList? removed) @@ -285,13 +286,13 @@ internal void OnNodeCollectionChanged( { IndexesChanged?.Invoke( this, - new TreeSelectionModelIndexesChangedEventArgs(parentIndex, shiftIndex, shiftDelta)); + new TreeSelectionModelIndexesChangedEventArgs(parentIndex, shiftStartIndex, shiftEndIndex, shiftDelta)); } // Shift or clear the selected and anchor indexes according to the shift index/delta. var hadSelection = _selectedIndex != default; - var selectedIndexChanged = ShiftIndex(parentIndex, shiftIndex, shiftDelta, ref _selectedIndex); - var anchorIndexChanged = ShiftIndex(parentIndex, shiftIndex, shiftDelta, ref _anchorIndex); + var selectedIndexChanged = ShiftIndex(parentIndex, shiftStartIndex, shiftDelta, ref _selectedIndex); + var anchorIndexChanged = ShiftIndex(parentIndex, shiftStartIndex, shiftDelta, ref _anchorIndex); var selectedItemChanged = false; // Check that the selected index is still selected in the node. It can get diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelIndexesChangedEventArgs.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelIndexesChangedEventArgs.cs index 880c3af7..0c93234d 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelIndexesChangedEventArgs.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionModelIndexesChangedEventArgs.cs @@ -2,17 +2,41 @@ namespace Avalonia.Controls.Selection { + /// + /// Holds data for the event. + /// public class TreeSelectionModelIndexesChangedEventArgs : EventArgs { - public TreeSelectionModelIndexesChangedEventArgs(IndexPath parentIndex, int startIndex, int delta) + public TreeSelectionModelIndexesChangedEventArgs( + IndexPath parentIndex, + int startIndex, + int endIndex, + int delta) { ParentIndex = parentIndex; StartIndex = startIndex; + EndIndex = endIndex; Delta = delta; } + /// + /// Gets the index of the parent item. + /// public IndexPath ParentIndex { get; } + + /// + /// Gets the inclusive start index of the range of indexes that changed. + /// public int StartIndex { get; } + + /// + /// Gets the exclusive end index of the range of indexes that changed. + /// + public int EndIndex { get; } + + /// + /// Gets the delta of the change; i.e. the number of indexes added or removed. + /// public int Delta { get; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionNode.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionNode.cs index 4e253c75..9706cd63 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionNode.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeSelectionNode.cs @@ -123,7 +123,8 @@ protected override void OnSourceCollectionChangeStarted() protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e) { - var shiftIndex = 0; + var shiftStartIndex = 0; + var shiftEndIndex = -1; var shiftDelta = 0; var indexesChanged = false; List? removed = null; @@ -132,25 +133,34 @@ protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventAr switch (e.Action) { case NotifyCollectionChangedAction.Add: - shiftIndex = e.NewStartingIndex; + shiftStartIndex = e.NewStartingIndex; shiftDelta = e.NewItems!.Count; - indexesChanged = OnItemsAdded(shiftIndex, e.NewItems).ShiftDelta > 0; + indexesChanged = OnItemsAdded(shiftStartIndex, e.NewItems).ShiftDelta > 0; break; case NotifyCollectionChangedAction.Remove: - shiftIndex = e.OldStartingIndex; + shiftStartIndex = e.OldStartingIndex; shiftDelta = -e.OldItems!.Count; - var change = OnItemsRemoved(shiftIndex, e.OldItems); + var change = OnItemsRemoved(shiftStartIndex, e.OldItems); indexesChanged = change.ShiftDelta != 0; removed = change.RemovedItems; break; case NotifyCollectionChangedAction.Replace: var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!); var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems!); - shiftIndex = removeChange.ShiftIndex; + shiftStartIndex = removeChange.ShiftIndex; shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta; indexesChanged = shiftDelta != 0; removed = removeChange.RemovedItems; break; + case NotifyCollectionChangedAction.Move: + shiftStartIndex = Math.Min(e.OldStartingIndex, e.NewStartingIndex); + shiftEndIndex = Math.Max(e.OldStartingIndex, e.NewStartingIndex); + shiftDelta = e.OldStartingIndex < e.NewStartingIndex ? -e.OldItems!.Count : e.OldItems!.Count; + var moveRemoveChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems!); + var moveAddChange = OnItemsAdded(e.NewStartingIndex, e.NewItems!); + indexesChanged = shiftDelta != 0; + removed = moveRemoveChange.RemovedItems; + break; case NotifyCollectionChangedAction.Reset: OnSourceReset(); break; @@ -161,29 +171,33 @@ protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventAr // Adjust the paths of any child nodes. if (_children?.Count > 0 && shiftDelta != 0) { - for (var i = shiftIndex; i < _children.Count; ++i) + for (var i = shiftStartIndex; i < _children.Count; ++i) { var child = _children[i]; - if (shiftDelta < 1 && i >= shiftIndex && i < shiftIndex - shiftDelta) + if (shiftDelta < 1 && i >= shiftStartIndex && i < shiftStartIndex - shiftDelta) { child?.AncestorRemoved(ref removed); } else { - child?.AncestorIndexChanged(Path, shiftIndex, shiftDelta); + child?.AncestorIndexChanged(Path, shiftStartIndex, shiftDelta); indexesChanged = true; } } if (shiftDelta > 0) - _children.InsertMany(shiftIndex, null, shiftDelta); + _children.InsertMany(shiftStartIndex, null, shiftDelta); else - _children.RemoveRange(shiftIndex, -shiftDelta); + _children.RemoveRange(shiftStartIndex, -shiftDelta); } - if (shiftDelta != 0 || removed?.Count> 0) - _owner.OnNodeCollectionChanged(Path, shiftIndex, shiftDelta, indexesChanged, removed); + if (shiftDelta != 0 || removed?.Count > 0) + { + if (shiftEndIndex == -1) + shiftEndIndex = ItemsView?.Count ?? 0; + _owner.OnNodeCollectionChanged(Path, shiftStartIndex, shiftEndIndex, shiftDelta, indexesChanged, removed); + } } protected override void OnSourceCollectionChangeFinished() diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs index 7e37c1e7..9f95003c 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs @@ -1171,6 +1171,207 @@ public void Replacing_Selected_Child_Item_Updates_State() Assert.Equal(1, selectedItemRaised); } + [AvaloniaFact(Timeout = 10000)] + public void Moving_Selected_Root_Item_Updates_State() + { + var data = CreateData(); + var target = CreateTarget(data); + var indexesChangedRaised = 0; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var selectedItemRaised = 0; + + target.Select(new IndexPath(1)); + + target.IndexesChanged += (s, e) => + { + ++indexesChangedRaised; + }; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++selectedItemRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + ++selectionChangedRaised; + }; + + data.Move(1, 3); + + Assert.Equal(0, target.Count); + Assert.Equal(default, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(1, selectedItemRaised); + } + + [AvaloniaFact(Timeout = 10000)] + public void Moving_Selected_Child_Item_Updates_State() + { + var data = CreateData(); + var target = CreateTarget(data); + var indexesChangedRaised = 0; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var selectedItemRaised = 0; + + target.Select(new IndexPath(1, 1)); + + target.IndexesChanged += (s, e) => + { + ++indexesChangedRaised; + }; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++selectedItemRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + ++selectionChangedRaised; + }; + + data[1].Children!.Move(1, 2); + + Assert.Equal(0, target.Count); + Assert.Equal(default, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(1, selectedItemRaised); + } + + [AvaloniaFact(Timeout = 10000)] + public void Moving_Unselected_Child_Item_From_Before_Selected_Item_To_After_Updates_State() + { + var data = CreateData(); + var target = CreateTarget(data); + var indexesChangedRaised = 0; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var selectedItemRaised = 0; + + target.Select(new IndexPath(1, 1)); + + target.IndexesChanged += (s, e) => + { + Assert.Equal(new(1), e.ParentIndex); + Assert.Equal(0, e.StartIndex); + Assert.Equal(2, e.EndIndex); + Assert.Equal(-1, e.Delta); + ++indexesChangedRaised; + }; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++selectedItemRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + ++selectionChangedRaised; + }; + + data[1].Children!.Move(0, 2); + + Assert.Equal(1, target.Count); + Assert.Equal(new(1, 0), target.SelectedIndex); + Assert.Equal([new(1, 0)], target.SelectedIndexes); + Assert.Equal("Node 1-1", target.SelectedItem?.Caption); + Assert.Equal(["Node 1-1"], target.SelectedItems.Select(x => x?.Caption)); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, selectedItemRaised); + } + + [AvaloniaFact(Timeout = 10000)] + public void Moving_Unselected_Child_Item_From_After_Selected_Item_To_Before_Updates_State() + { + var data = CreateData(); + var target = CreateTarget(data); + var indexesChangedRaised = 0; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var selectedItemRaised = 0; + + target.Select(new IndexPath(1, 1)); + + target.IndexesChanged += (s, e) => + { + Assert.Equal(new(1), e.ParentIndex); + Assert.Equal(0, e.StartIndex); + Assert.Equal(2, e.EndIndex); + Assert.Equal(1, e.Delta); + ++indexesChangedRaised; + }; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++selectedItemRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + ++selectionChangedRaised; + }; + + data[1].Children!.Move(2, 0); + + Assert.Equal(1, target.Count); + Assert.Equal(new(1, 2), target.SelectedIndex); + Assert.Equal([new(1, 2)], target.SelectedIndexes); + Assert.Equal("Node 1-1", target.SelectedItem?.Caption); + Assert.Equal(["Node 1-1"], target.SelectedItems.Select(x => x?.Caption)); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, selectedItemRaised); + } + + [AvaloniaFact(Timeout = 10000)] public void Resetting_Root_Updates_State() { From 9d17719662e1c901173ba541b99d0a45b976a902 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 26 Nov 2024 11:00:02 +0100 Subject: [PATCH 29/36] Remove unneeded usings. --- .../Selection/SelectionNodeBase.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs index 97959b94..c0eddd27 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectionNodeBase.cs @@ -2,8 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; -using System.Reflection; -using System.Threading.Channels; using Avalonia.Controls.Utils; #nullable enable From d81bec60ade4dac2660ceb256c869596d60ed9d4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 26 Nov 2024 11:02:35 +0100 Subject: [PATCH 30/36] Update actions dotnet SDK version. --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1eb957b8..86347d20 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -13,7 +13,7 @@ jobs: with: dotnet-version: | 6.0.x - 7.0.x + 8.0.x - name: Install dependencies run: dotnet restore - name: Build From c184faa1e27b961d4cc75567bbd4f782792f8a23 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 26 Nov 2024 11:04:54 +0100 Subject: [PATCH 31/36] Use .net 8 SDK for build. --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index e6e67e4e..d3492186 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "7.0.101", + "version": "8.0.101", "rollForward": "latestFeature" } } From 7b9571ac7b89715bb94a912d012f06f7f5acd934 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 27 Nov 2024 15:27:17 +0100 Subject: [PATCH 32/36] Added failing test for #298. --- .../TreeDataGridTests_Flat.cs | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs index 083396e7..4f7cbd67 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs @@ -1,19 +1,15 @@ -using Avalonia.Collections; -using Avalonia.Controls.Models.TreeDataGrid; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Selection; -using Avalonia.Layout; -using Avalonia.LogicalTree; -using Avalonia.Styling; -using Avalonia.VisualTree; -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; -using Avalonia.Controls.Embedding; -using Avalonia.Headless; +using Avalonia.Collections; +using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; using Avalonia.Headless.XUnit; +using Avalonia.Styling; using Avalonia.Threading; +using Avalonia.VisualTree; using Xunit; using Enumerable = System.Linq.Enumerable; @@ -381,6 +377,53 @@ public void Columns_Are_Correctly_Sized_After_Changing_Source() Assert.Equal(20, columns[2].ActualWidth); } + [AvaloniaFact(Timeout = 10000)] + public void Should_Correctly_Align_Columns_When_Vertically_Scrolling_With_First_Column_Unrealized() + { + // Issue #298 + static void AssertRealizedCells(TreeDataGrid target) + { + var rows = target.RowsPresenter!.GetVisualChildren().Cast(); + + foreach (var row in rows) + { + var cells = row.CellsPresenter!.GetRealizedElements() + .Cast() + .OrderBy(x => x.ColumnIndex) + .ToList(); + + Assert.Equal(3, cells.Count); + Assert.Equal(1, cells[0].ColumnIndex); + Assert.Equal(100, cells[0].Bounds.Left); + Assert.Equal(150, cells[1].Bounds.Left); + Assert.Equal(200, cells[2].Bounds.Left); + } + } + + var (target, items) = CreateTarget(columns: + [ + new TextColumn("ID", x => x.Id, width: new GridLength(100, GridUnitType.Pixel)), + new TextColumn("Title1", x => x.Title, width: new GridLength(50, GridUnitType.Pixel)), + new TextColumn("Title2", x => x.Title, width: new GridLength(50, GridUnitType.Pixel)), + new TextColumn("Title3", x => x.Title, width: new GridLength(50, GridUnitType.Pixel)), + ]); + + // Scroll horizontally and check that the realized cells are positioned correctly. + target.Scroll!.Offset = new Vector(120, 0); + target.UpdateLayout(); + AssertRealizedCells(target); + + // Scroll down a row and check that the realized cells are positioned correctly. + target.Scroll!.Offset = new Vector(120, 10); + target.UpdateLayout(); + AssertRealizedCells(target); + + // Now scroll back vertically and check once more. + target.Scroll!.Offset = new Vector(120, 0); + target.UpdateLayout(); + AssertRealizedCells(target); + } + public class RemoveItems { [AvaloniaFact(Timeout = 10000)] From 39aa9c1993a1fa8f6cbc91db49c3e5ccef773dc7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 27 Nov 2024 15:36:47 +0100 Subject: [PATCH 33/36] Get X position from Columns. For columnar layouts, there should usually be be no need to estimate the x position of the column start in the viewport, as this information can be queried from `IColumns`. Make a new `GetOrEstimateAnchorElementForViewport` virtual method and override its behavior for columnar presenters. Fixes #298 --- .../TreeDataGridColumnarPresenterBase.cs | 10 ++++++++++ .../Primitives/TreeDataGridPresenterBase.cs | 20 ++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs index 6700470a..6e1129e5 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs @@ -23,6 +23,16 @@ protected sealed override Size GetInitialConstraint(Control element, int index, return new Size(Math.Min(availableSize.Width, column.MaxActualWidth), availableSize.Height); } + protected override (int index, double position) GetOrEstimateAnchorElementForViewport( + double viewportStart, + double viewportEnd, + int itemCount) + { + if (Columns?.GetColumnAt(viewportStart) is var (index, position) && index >= 0) + return (index, position); + return base.GetOrEstimateAnchorElementForViewport(viewportStart, viewportEnd, itemCount); + } + protected sealed override bool NeedsFinalMeasurePass(int firstIndex, IReadOnlyList elements) { var columns = Columns!; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 483acddb..7cb9e7b0 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -397,6 +397,20 @@ protected override Size ArrangeOverride(Size finalSize) } } + protected virtual (int index, double position) GetOrEstimateAnchorElementForViewport( + double viewportStart, + double viewportEnd, + int itemCount) + { + Debug.Assert(_realizedElements is not null); + + return _realizedElements.GetOrEstimateAnchorElementForViewport( + viewportStart, + viewportEnd, + itemCount, + ref _lastEstimatedElementSizeU); + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -539,11 +553,7 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList items, Siz // Get or estimate the anchor element from which to start realization. var itemCount = items.Count; - var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( - viewportStart, - viewportEnd, - itemCount, - ref _lastEstimatedElementSizeU); + var (anchorIndex, anchorU) = GetOrEstimateAnchorElementForViewport(viewportStart, viewportEnd, itemCount); // Check if the anchor element is not within the currently realized elements. var disjunct = anchorIndex < _realizedElements.FirstIndex || From df2b03ae4fda1b6f72919777c1f6aa8ec3223818 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 27 Nov 2024 12:05:02 +0100 Subject: [PATCH 34/36] Include header scroller in test template. Requires another workaround for https://github.com/AvaloniaUI/Avalonia/issues/15075 as the header presenter needs a height of 0 in the tests. --- .../Primitives/TreeDataGridPresenterBase.cs | 5 ++--- .../TestTemplates.cs | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 7cb9e7b0..11e88c52 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -639,9 +639,8 @@ private Rect EstimateViewport(Size availableSize) { if (!c.Bounds.Equals(default) && c.TransformToVisual(this) is Matrix transform) { - return new Rect(0, 0, c.Bounds.Width, c.Bounds.Height) - .TransformToAABB(transform) - .Intersect(new(0, 0, double.PositiveInfinity, double.PositiveInfinity)); + var r = new Rect(0, 0, c.Bounds.Width, c.Bounds.Height).TransformToAABB(transform); + return Intersect(r, new(0, 0, double.PositiveInfinity, double.PositiveInfinity)); } c = c?.GetVisualParent(); diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs index ab8d7be7..c01d8cce 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs @@ -61,12 +61,20 @@ public static IControlTemplate TreeDataGridTemplate() { Children = { - new TreeDataGridColumnHeadersPresenter + new ScrollViewer { - Name = "PART_ColumnHeadersPresenter", + Name = "PART_HeaderScrollViewer", + Template = ScrollViewerTemplate(), + Height = 0, + HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, [DockPanel.DockProperty] = Dock.Top, - [!TreeDataGridColumnHeadersPresenter.ElementFactoryProperty] = x[!TreeDataGrid.ElementFactoryProperty], - [!TreeDataGridColumnHeadersPresenter.ItemsProperty] = x[!TreeDataGrid.ColumnsProperty], + Content = new TreeDataGridColumnHeadersPresenter + { + Name = "PART_ColumnHeadersPresenter", + [!TreeDataGridColumnHeadersPresenter.ElementFactoryProperty] = x[!TreeDataGrid.ElementFactoryProperty], + [!TreeDataGridColumnHeadersPresenter.ItemsProperty] = x[!TreeDataGrid.ColumnsProperty], + }.RegisterInNameScope(ns), }.RegisterInNameScope(ns), new ScrollViewer { From 4d3933b4606e57bf093ab0f3bbf799bc018e109a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Dec 2024 11:28:53 +0100 Subject: [PATCH 35/36] Add failing test for horizontal scrollbar. When items are removed, the horizontal scrollbar is reset even if the column headers do not fit in the viewport. --- .../TreeDataGridTests_Flat.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs index 4f7cbd67..5afa60f7 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TreeDataGridTests_Flat.cs @@ -560,6 +560,69 @@ public void Can_Remove_Selected_Item_Sorted() } } + [AvaloniaFact(Timeout = 10000)] + public void Should_Show_Horizontal_ScrollBar() + { + var (target, items) = CreateTarget(columns: + [ + new TextColumn("ID", x => x.Id, width: new GridLength(100, GridUnitType.Pixel)), + new TextColumn("Title1", x => x.Title, width: new GridLength(100, GridUnitType.Pixel)), + ]); + var scroll = Assert.IsType(target.Scroll); + var headerScroll = Assert.IsType( + target.GetVisualDescendants().Single(x => x.Name == "PART_HeaderScrollViewer")); + + Assert.Equal(new(100, 100), scroll.Viewport); + Assert.Equal(new(200, 1000), scroll.Extent); + Assert.Equal(new(100, 0), headerScroll.Viewport); + Assert.Equal(new(200, 0), headerScroll.Extent); + } + + [AvaloniaFact(Timeout = 10000)] + public void Should_Show_Horizontal_ScrollBar_With_No_Initial_Rows() + { + var (target, items) = CreateTarget(columns: + [ + new TextColumn("ID", x => x.Id, width: new GridLength(100, GridUnitType.Pixel)), + new TextColumn("Title1", x => x.Title, width: new GridLength(100, GridUnitType.Pixel)), + ], itemCount: 0); + var scroll = Assert.IsType(target.Scroll); + var headerScroll = Assert.IsType( + target.GetVisualDescendants().Single(x => x.Name == "PART_HeaderScrollViewer")); + + Assert.Equal(new(100, 100), scroll.Viewport); + Assert.Equal(new(200, 100), scroll.Extent); + Assert.Equal(new(100, 0), headerScroll.Viewport); + Assert.Equal(new(200, 0), headerScroll.Extent); + } + + [AvaloniaFact(Timeout = 10000)] + public void Should_Preserve_Horizontal_ScrollBar_When_Rows_Removed() + { + var (target, items) = CreateTarget(columns: + [ + new TextColumn("ID", x => x.Id, width: new GridLength(100, GridUnitType.Pixel)), + new TextColumn("Title1", x => x.Title, width: new GridLength(100, GridUnitType.Pixel)), + ]); + var scroll = Assert.IsType(target.Scroll); + var headerScroll = Assert.IsType( + target.GetVisualDescendants().Single(x => x.Name == "PART_HeaderScrollViewer")); + + scroll.PropertyChanged += (s, e) => + { + if (e.Property == ScrollViewer.ExtentProperty) + { + } + }; + items.Clear(); + target.UpdateLayout(); + + Assert.Equal(new(100, 100), scroll.Viewport); + Assert.Equal(new(200, 100), scroll.Extent); + Assert.Equal(new(100, 0), headerScroll.Viewport); + Assert.Equal(new(200, 0), headerScroll.Extent); + } + private static void AssertRowIndexes(TreeDataGrid target, int firstRowIndex, int rowCount) { var presenter = target.RowsPresenter; From 56eff203e2d8b5250bdc55d559e1399a061dad43 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Dec 2024 11:30:05 +0100 Subject: [PATCH 36/36] If we have no rows, get width from the columns. Fixes horizontal scrollbar disappearing when all rows removed. --- .../Primitives/TreeDataGridRowsPresenter.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index fb371722..e946bc09 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -57,6 +57,17 @@ protected override void UnrealizeElementOnItemRemoved(Control element) ChildIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, ((TreeDataGridRow)element).RowIndex)); } + protected override Size MeasureOverride(Size availableSize) + { + var result = base.MeasureOverride(availableSize); + + // If we have no rows, then get the width from the columns. + if (Columns is not null && (Items is null || Items.Count == 0)) + result = result.WithWidth(Columns.GetEstimatedWidth(availableSize.Width)); + + return result; + } + protected override Size ArrangeOverride(Size finalSize) { Columns?.CommitActualWidths();