From 7d3f490b046f113c191316ae1180af2620109caf Mon Sep 17 00:00:00 2001 From: Betta_Fish <96322503+zxbmmmmmmmmm@users.noreply.github.com> Date: Sat, 8 Mar 2025 22:41:32 +0800 Subject: [PATCH 01/12] [Grid] Add RowSpacing and ColumnSpacing (#18077) * Add spacing properties * Add spacing while computing final offsets * Add spacing while calculating desired size * Add unit tests * fix missing spacing arrangement * draw grid line * Add property changed handler * add new unit test * Resolve star spacing * fix AffectsMeasure * overflow unit test * Clean up --------- Co-authored-by: Poker --- src/Avalonia.Controls/Grid.cs | 110 ++++++++++++++---- .../Avalonia.Controls.UnitTests/GridTests.cs | 85 ++++++++++++++ 2 files changed, 172 insertions(+), 23 deletions(-) diff --git a/src/Avalonia.Controls/Grid.cs b/src/Avalonia.Controls/Grid.cs index 2e745cd364..0a55a44ad2 100644 --- a/src/Avalonia.Controls/Grid.cs +++ b/src/Avalonia.Controls/Grid.cs @@ -25,6 +25,8 @@ namespace Avalonia.Controls static Grid() { ShowGridLinesProperty.Changed.AddClassHandler(OnShowGridLinesPropertyChanged); + ColumnSpacingProperty.Changed.AddClassHandler(OnSpacingPropertyChanged); + RowSpacingProperty.Changed.AddClassHandler(OnSpacingPropertyChanged); IsSharedSizeScopeProperty.Changed.AddClassHandler(DefinitionBase.OnIsSharedSizeScopePropertyChanged); ColumnProperty.Changed.AddClassHandler(OnCellAttachedPropertyChanged); @@ -32,6 +34,7 @@ namespace Avalonia.Controls RowProperty.Changed.AddClassHandler(OnCellAttachedPropertyChanged); RowSpanProperty.Changed.AddClassHandler(OnCellAttachedPropertyChanged); + AffectsMeasure(ColumnSpacingProperty, RowSpacingProperty); AffectsParentMeasure(ColumnProperty, ColumnSpanProperty, RowProperty, RowSpanProperty); } @@ -161,6 +164,24 @@ namespace Avalonia.Controls set => SetValue(ShowGridLinesProperty, value); } + /// + /// Gets or sets the size of the spacing to place between grid rows. + /// + public double RowSpacing + { + get => GetValue(RowSpacingProperty); + set => SetValue(RowSpacingProperty, value); + } + + /// + /// Gets or sets the size of the spacing to place between grid columns. + /// + public double ColumnSpacing + { + get => GetValue(ColumnSpacingProperty); + set => SetValue(ColumnSpacingProperty, value); + } + /// /// Returns a ColumnDefinitions of column definitions. /// @@ -299,7 +320,7 @@ namespace Avalonia.Controls // the cells belonging to them. // // However, there are cases when topology of a grid causes cyclical - // size dependences. For example: + // size dependencies. For example: // // // column width="Auto" column width="*" @@ -425,17 +446,19 @@ namespace Avalonia.Controls // MeasureCellsGroup(extData.CellGroup1, constraint, false, false); - + double combinedRowSpacing = RowSpacing * (RowDefinitions.Count - 1); + double combinedColumnSpacing = ColumnSpacing * (ColumnDefinitions.Count - 1); + Size innerAvailableSize = new Size(constraint.Width - combinedRowSpacing, constraint.Height - combinedColumnSpacing); { // after Group1 is measured, only Group3 may have cells belonging to Auto rows. bool canResolveStarsV = !HasGroup3CellsInAutoRows; if (canResolveStarsV) { - if (HasStarCellsV) { ResolveStar(DefinitionsV, constraint.Height); } - MeasureCellsGroup(extData.CellGroup2, constraint, false, false); - if (HasStarCellsU) { ResolveStar(DefinitionsU, constraint.Width); } - MeasureCellsGroup(extData.CellGroup3, constraint, false, false); + if (HasStarCellsV) { ResolveStar(DefinitionsV, innerAvailableSize.Height); } + MeasureCellsGroup(extData.CellGroup2, innerAvailableSize, false, false); + if (HasStarCellsU) { ResolveStar(DefinitionsU, innerAvailableSize.Width); } + MeasureCellsGroup(extData.CellGroup3, innerAvailableSize, false, false); } else { @@ -444,9 +467,9 @@ namespace Avalonia.Controls bool canResolveStarsU = extData.CellGroup2 > PrivateCells.Length; if (canResolveStarsU) { - if (HasStarCellsU) { ResolveStar(DefinitionsU, constraint.Width); } - MeasureCellsGroup(extData.CellGroup3, constraint, false, false); - if (HasStarCellsV) { ResolveStar(DefinitionsV, constraint.Height); } + if (HasStarCellsU) { ResolveStar(DefinitionsU, innerAvailableSize.Width); } + MeasureCellsGroup(extData.CellGroup3, innerAvailableSize, false, false); + if (HasStarCellsV) { ResolveStar(DefinitionsV, innerAvailableSize.Height); } } else { @@ -462,7 +485,7 @@ namespace Avalonia.Controls double[] group2MinSizes = CacheMinSizes(extData.CellGroup2, false); double[] group3MinSizes = CacheMinSizes(extData.CellGroup3, true); - MeasureCellsGroup(extData.CellGroup2, constraint, false, true); + MeasureCellsGroup(extData.CellGroup2, innerAvailableSize, false, true); do { @@ -472,14 +495,14 @@ namespace Avalonia.Controls ApplyCachedMinSizes(group3MinSizes, true); } - if (HasStarCellsU) { ResolveStar(DefinitionsU, constraint.Width); } - MeasureCellsGroup(extData.CellGroup3, constraint, false, false); + if (HasStarCellsU) { ResolveStar(DefinitionsU, innerAvailableSize.Width); } + MeasureCellsGroup(extData.CellGroup3, innerAvailableSize, false, false); // Reset cached Group2Widths ApplyCachedMinSizes(group2MinSizes, false); - if (HasStarCellsV) { ResolveStar(DefinitionsV, constraint.Height); } - MeasureCellsGroup(extData.CellGroup2, constraint, cnt == c_layoutLoopMaxCount, false, out hasDesiredSizeUChanged); + if (HasStarCellsV) { ResolveStar(DefinitionsV, innerAvailableSize.Height); } + MeasureCellsGroup(extData.CellGroup2, innerAvailableSize, cnt == c_layoutLoopMaxCount, false, out hasDesiredSizeUChanged); } while (hasDesiredSizeUChanged && ++cnt <= c_layoutLoopMaxCount); } @@ -489,8 +512,8 @@ namespace Avalonia.Controls MeasureCellsGroup(extData.CellGroup4, constraint, false, false); gridDesiredSize = new Size( - CalculateDesiredSize(DefinitionsU), - CalculateDesiredSize(DefinitionsV)); + CalculateDesiredSize(DefinitionsU) + ColumnSpacing * (DefinitionsU.Count - 1), + CalculateDesiredSize(DefinitionsV) + RowSpacing * (DefinitionsU.Count - 1)); } } finally @@ -524,9 +547,12 @@ namespace Avalonia.Controls else { Debug.Assert(DefinitionsU.Count > 0 && DefinitionsV.Count > 0); - - SetFinalSize(DefinitionsU, arrangeSize.Width, true); - SetFinalSize(DefinitionsV, arrangeSize.Height, false); + double columnSpacing = ColumnSpacing; + double rowSpacing = RowSpacing; + double combinedRowSpacing = rowSpacing * (RowDefinitions.Count - 1); + double combinedColumnSpacing = columnSpacing * (ColumnDefinitions.Count - 1); + SetFinalSize(DefinitionsU, arrangeSize.Width - combinedColumnSpacing, true); + SetFinalSize(DefinitionsV, arrangeSize.Height - combinedRowSpacing, false); var children = Children; @@ -540,14 +566,13 @@ namespace Avalonia.Controls int rowSpan = PrivateCells[currentCell].RowSpan; Rect cellRect = new Rect( - columnIndex == 0 ? 0.0 : DefinitionsU[columnIndex].FinalOffset, - rowIndex == 0 ? 0.0 : DefinitionsV[rowIndex].FinalOffset, + columnIndex == 0 ? 0.0 : DefinitionsU[columnIndex].FinalOffset + (columnSpacing * columnIndex), + rowIndex == 0 ? 0.0 : DefinitionsV[rowIndex].FinalOffset + (rowSpacing * rowIndex), GetFinalSizeForRange(DefinitionsU, columnIndex, columnSpan), GetFinalSizeForRange(DefinitionsV, rowIndex, rowSpan)); cell.Arrange(cellRect); - } // update render bound on grid lines renderer visual @@ -2088,7 +2113,7 @@ namespace Avalonia.Controls // double dpi = columns ? dpiScale.DpiScaleX : dpiScale.DpiScaleY; var dpi = (VisualRoot as ILayoutRoot)?.LayoutScaling ?? 1.0; double[] roundingErrors = RoundingErrors; - double roundedTakenSize = 0.0; + double roundedTakenSize = 0; // round each of the allocated sizes, keeping track of the deltas for (int i = 0; i < definitions.Count; ++i) @@ -2363,6 +2388,17 @@ namespace Avalonia.Controls grid.SetFlags((bool)e.NewValue!, Flags.ShowGridLinesPropertyValue); } + private static void OnSpacingPropertyChanged(AvaloniaObject d, AvaloniaPropertyChangedEventArgs e) + { + Grid grid = (Grid)d; + + if (grid._extData != null + && grid.ListenToNotifications) + { + grid.CellsStructureDirty = true; + } + } + private static void OnCellAttachedPropertyChanged(AvaloniaObject d, AvaloniaPropertyChangedEventArgs e) { if (d is Visual child) @@ -2674,6 +2710,18 @@ namespace Avalonia.Controls public static readonly StyledProperty ShowGridLinesProperty = AvaloniaProperty.Register(nameof(ShowGridLines)); + /// + /// Defines the property. + /// + public static readonly StyledProperty RowSpacingProperty = + AvaloniaProperty.Register(nameof(RowSpacing)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ColumnSpacingProperty = + AvaloniaProperty.Register(nameof(ColumnSpacingProperty)); + /// /// Column property. This is an attached property. /// Grid defines Column property, so that it can be set @@ -3269,6 +3317,14 @@ namespace Avalonia.Controls drawingContext, grid.ColumnDefinitions[i].FinalOffset, 0.0, grid.ColumnDefinitions[i].FinalOffset, _lastArrangeSize.Height); + + if (grid.ColumnSpacing != 0) + { + DrawGridLine( + drawingContext, + grid.ColumnDefinitions[i].FinalOffset - grid.ColumnSpacing, 0.0, + grid.ColumnDefinitions[i].FinalOffset - grid.ColumnSpacing, _lastArrangeSize.Height); + } } for (int i = 1; i < grid.RowDefinitions.Count; ++i) @@ -3277,6 +3333,14 @@ namespace Avalonia.Controls drawingContext, 0.0, grid.RowDefinitions[i].FinalOffset, _lastArrangeSize.Width, grid.RowDefinitions[i].FinalOffset); + + if (grid.RowSpacing != 0) + { + DrawGridLine( + drawingContext, + 0.0, grid.RowDefinitions[i].FinalOffset - grid.RowSpacing, + _lastArrangeSize.Width, grid.RowDefinitions[i].FinalOffset - grid.RowSpacing); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/GridTests.cs b/tests/Avalonia.Controls.UnitTests/GridTests.cs index 339a4a7cdd..3eebdde255 100644 --- a/tests/Avalonia.Controls.UnitTests/GridTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridTests.cs @@ -1656,6 +1656,91 @@ namespace Avalonia.Controls.UnitTests Assert.False(grid.IsArrangeValid); } + [Fact] + public void Should_Grid_Controls_With_Spacing() + { + var target = new Grid + { + RowSpacing = 10, + ColumnSpacing = 10, + RowDefinitions = RowDefinitions.Parse("100,100"), + ColumnDefinitions = ColumnDefinitions.Parse("100,100"), + Children = + { + new Border(), + new Border { [Grid.ColumnProperty] = 1 }, + new Border { [Grid.RowProperty] = 1 }, + new Border { [Grid.RowProperty] = 1, [Grid.ColumnProperty] = 1 } + } + }; + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Rect(0, 0, 210, 210), target.Bounds); + Assert.Equal(new Rect(0, 0, 100, 100), target.Children[0].Bounds); + Assert.Equal(new Rect(110, 0, 100, 100), target.Children[1].Bounds); + Assert.Equal(new Rect(0, 110, 100, 100), target.Children[2].Bounds); + Assert.Equal(new Rect(110, 110, 100, 100), target.Children[3].Bounds); + } + + [Fact] + public void Should_Grid_Controls_With_Spacing_Complicated() + { + var target = new Grid + { + Width = 200, + Height = 200, + RowSpacing = 10, + ColumnSpacing = 10, + RowDefinitions = RowDefinitions.Parse("50,*,2*,Auto"), + ColumnDefinitions = ColumnDefinitions.Parse("50,*,2*,Auto"), + Children = + { + new Border(), + new Border { [Grid.RowProperty] = 1, [Grid.ColumnProperty] = 1 }, + new Border { [Grid.RowProperty] = 2, [Grid.ColumnProperty] = 2 }, + new Border { [Grid.RowProperty] = 3, [Grid.ColumnProperty] = 3, Width = 30, Height = 30 }, + }, + }; + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Rect(0, 0, 200, 200), target.Bounds); + Assert.Equal(new Rect(0, 0, 50, 50), target.Children[0].Bounds); + Assert.Equal(new Rect(60, 60, 30, 30), target.Children[1].Bounds); + Assert.Equal(new Rect(100, 100, 60, 60), target.Children[2].Bounds); + Assert.Equal(new Rect(170, 170, 30, 30), target.Children[3].Bounds); + } + + [Fact] + public void Should_Grid_Controls_With_Spacing_Overflow() + { + var target = new Grid + { + Width = 100, + Height = 100, + ColumnSpacing = 20, + RowSpacing = 20, + ColumnDefinitions = ColumnDefinitions.Parse("30,*,*,Auto"), + RowDefinitions = RowDefinitions.Parse("30,*,*,Auto"), + Children = + { + new Border(), + new Border { [Grid.RowProperty] = 1, [Grid.ColumnProperty] = 1 }, + new Border { [Grid.RowProperty] = 2, [Grid.ColumnProperty] = 2 }, + new Border { [Grid.RowProperty] = 3, [Grid.ColumnProperty] = 3, Width = 30, Height = 30 }, + }, + }; + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Rect(0, 0, 100, 100), target.Bounds); + Assert.Equal(new Rect(0, 0, 30, 30), target.Children[0].Bounds); + Assert.Equal(new Rect(50, 50, 0, 0), target.Children[1].Bounds); + Assert.Equal(new Rect(70, 70, 0, 0), target.Children[2].Bounds); + Assert.Equal(new Rect(90, 90, 30, 30), target.Children[3].Bounds); + } + private class TestControl : Control { public Size MeasureSize { get; set; } From 8a7945e492c1bbac60313a4af4b38e56a3eda7af Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Sat, 8 Mar 2025 09:43:48 -0500 Subject: [PATCH 02/12] Bring control into view only if control isn't properly visible in viewport (#18359) * bring control into view only if control isn't currently in viewport * fix margin add comments * add more bring to view tests --- .../Presenters/ScrollContentPresenter.cs | 67 ++++-- .../Presenters/ScrollContentPresenterTests.cs | 193 ++++++++++++++++++ 2 files changed, 239 insertions(+), 21 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 88e10c3ba3..08ee08aec7 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -257,28 +257,12 @@ namespace Avalonia.Controls.Presenters return false; } - var rect = targetRect.TransformToAABB(transform.Value); - var offset = Offset; + var rectangle = targetRect.TransformToAABB(transform.Value).Deflate(new Thickness(Child.Margin.Left, Child.Margin.Top, 0, 0)); + Rect viewport = new Rect(Offset.X, Offset.Y, Viewport.Width, Viewport.Height); - if (rect.Bottom > offset.Y + Viewport.Height) - { - offset = offset.WithY((rect.Bottom - Viewport.Height) + Child.Margin.Top); - } - - if (rect.Y < offset.Y) - { - offset = offset.WithY(rect.Y); - } - - if (rect.Right > offset.X + Viewport.Width) - { - offset = offset.WithX((rect.Right - Viewport.Width) + Child.Margin.Left); - } - - if (rect.X < offset.X) - { - offset = offset.WithX(rect.X); - } + double minX = ComputeScrollOffsetWithMinimalScroll(viewport.Left, viewport.Right, rectangle.Left, rectangle.Right); + double minY = ComputeScrollOffsetWithMinimalScroll(viewport.Top, viewport.Bottom, rectangle.Top, rectangle.Bottom); + var offset = new Vector(minX, minY); if (Offset.NearlyEquals(offset)) { @@ -293,6 +277,47 @@ namespace Avalonia.Controls.Presenters return !Offset.NearlyEquals(oldOffset); } + /// + /// Computes the closest offset to ensure most of the child is visible in the viewport along an axis. + /// + /// The left or top of the viewport + /// The right or bottom of the viewport + /// The left or top of the child + /// The right or bottom of the child + /// + internal static double ComputeScrollOffsetWithMinimalScroll( + double viewportStart, + double viewportEnd, + double childStart, + double childEnd) + { + // If child is at least partially above viewport, i.e. top of child is above viewport top and bottom of child is above viewport bottom. + bool isChildAbove = MathUtilities.LessThan(childStart, viewportStart) && MathUtilities.LessThan(childEnd, viewportEnd); + + // If child is at least partially below viewport, i.e. top of child is below viewport top and bottom of child is below viewport bottom. + bool isChildBelow = MathUtilities.GreaterThan(childEnd, viewportEnd) && MathUtilities.GreaterThan(childStart, viewportStart); + bool isChildLarger = (childEnd - childStart) > (viewportEnd - viewportStart); + + // Value if no updates is needed. The child is fully visible in the viewport, or the viewport is completely within the child's bounds + var res = viewportStart; + + // The child is above the viewport and is smaller than the viewport, or if the child's top is below the viewport top + // and is larger than the viewport, we align the child top to the top of the viewport + if ((isChildAbove && !isChildLarger) + || (isChildBelow && isChildLarger)) + { + res = childStart; + } + // The child is above the viewport and is larger than the viewport, or if the child's smaller but is below the viewport, + // we align the child's bottom to the bottom of the viewport + else if (isChildAbove || isChildBelow) + { + res = (childEnd - (viewportEnd - viewportStart)); + } + + return res; + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs index e70ab52043..8ef5398078 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Layout; using Avalonia.UnitTests; using Xunit; +using Xunit.Sdk; namespace Avalonia.Controls.UnitTests.Presenters { @@ -399,6 +400,198 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(new Vector(150, 150), target.Offset); } + [Fact] + public void BringDescendantIntoView_Should_Not_Move_Child_If_Completely_In_View() + { + Border border = new Border + { + Width = 100, + Height = 20 + }; + var content = new StackPanel() + { + Orientation = Orientation.Vertical, + Width = 100, + }; + + for(int i = 0; i < 100; i++) + { + // border position will be (0,60) + var child = i == 3 ? border : new Border + { + Width = 100, + Height = 20, + }; + content.Children.Add(child); + } + var target = new ScrollContentPresenter + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Width = 200, + Height = 100, + Content = new Decorator + { + Child = content + } + }; + + target.UpdateChild(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(0, 0, 100, 100)); + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + + Assert.Equal(new Vector(0, 0), target.Offset); + } + + [Fact] + public void BringDescendantIntoView_Should_Move_Child_At_Least_Partially_Above_Viewport() + { + Border border = new Border + { + Width = 100, + Height = 20 + }; + var content = new StackPanel() + { + Orientation = Orientation.Vertical, + Width = 100, + }; + + for(int i = 0; i < 100; i++) + { + // border position will be (0,60) + var child = i == 3 ? border : new Border + { + Width = 100, + Height = 20, + }; + content.Children.Add(child); + } + var target = new ScrollContentPresenter + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Width = 200, + Height = 100, + Content = new Decorator + { + Child = content + } + }; + + target.UpdateChild(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(0, 0, 100, 100)); + // move border to above the view port + target.Offset = new Vector(0, 90); + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + + Assert.Equal(new Vector(0, 60), target.Offset); + + // move border to partially above the view port + target.Offset = new Vector(0, 70); + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + + Assert.Equal(new Vector(0, 60), target.Offset); + } + + [Fact] + public void BringDescendantIntoView_Should_Not_Move_Child_If_Completely_Covers_Viewport() + { + Border border = new Border + { + Width = 100, + Height = 200 + }; + var content = new StackPanel() + { + Orientation = Orientation.Vertical, + Width = 100, + }; + + for (int i = 0; i < 100; i++) + { + // border position will be (0,60) + var child = i == 3 ? border : new Border + { + Width = 100, + Height = 20, + }; + content.Children.Add(child); + } + var target = new ScrollContentPresenter + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Width = 200, + Height = 100, + Content = new Decorator + { + Child = content + } + }; + + target.UpdateChild(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(0, 0, 100, 100)); + // move border such that it's partially above viewport and partially below viewport + target.Offset = new Vector(0, 90); + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + + Assert.Equal(new Vector(0, 90), target.Offset); + } + + [Fact] + public void BringDescendantIntoView_Should_Move_Child_At_Least_Partially_Below_Viewport() + { + Border border = new Border + { + Width = 100, + Height = 20 + }; + var content = new StackPanel() + { + Orientation = Orientation.Vertical, + Width = 100, + }; + + for (int i = 0; i < 100; i++) + { + // border position will be (0,180) + var child = i == 9 ? border : new Border + { + Width = 100, + Height = 20, + }; + content.Children.Add(child); + } + var target = new ScrollContentPresenter + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Width = 200, + Height = 100, + Content = new Decorator + { + Child = content + } + }; + + target.UpdateChild(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(0, 0, 100, 100)); + + // border is at (0, 180) and below the viewport + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + + Assert.Equal(new Vector(0, 100), target.Offset); + + // move border to partially below the view port + target.Offset = new Vector(0, 90); + target.BringDescendantIntoView(border, new Rect(border.Bounds.Size)); + } + [Fact] public void Nested_Presenters_Should_Scroll_Outer_When_Content_Exceeds_Viewport() { From 6e1cab99f67b608f953afcbbcef0a04a1f666c0c Mon Sep 17 00:00:00 2001 From: Rastislav Svoboda Date: Sat, 8 Mar 2025 16:05:02 +0100 Subject: [PATCH 03/12] Fix: TextBox having selection and pressing up / down arrows (#18385) --- src/Avalonia.Controls/TextBox.cs | 31 +++- .../TextBoxTests.cs | 174 ++++++++++++++++++ 2 files changed, 198 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 3d3dc87b01..90d17c432a 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1467,6 +1467,11 @@ namespace Avalonia.Controls { selection = DetectSelection(); + if (!selection && SelectionStart != SelectionEnd) + { + ClearSelectionAndMoveCaretToTextPosition(LogicalDirection.Backward); + } + _presenter.MoveCaretVertical(LogicalDirection.Backward); if (caretIndex != _presenter.CaretIndex) @@ -1489,6 +1494,11 @@ namespace Avalonia.Controls { selection = DetectSelection(); + if (!selection && SelectionStart != SelectionEnd) + { + ClearSelectionAndMoveCaretToTextPosition(LogicalDirection.Forward); + } + _presenter.MoveCaretVertical(); if (caretIndex != _presenter.CaretIndex) @@ -1983,13 +1993,9 @@ namespace Avalonia.Controls { if (selectionStart != selectionEnd) { - // clear the selection and move to the appropriate side of previous selection - var newPosition = direction > 0 ? - Math.Max(selectionStart, selectionEnd) : - Math.Min(selectionStart, selectionEnd); - SetCurrentValue(SelectionStartProperty, newPosition); - SetCurrentValue(SelectionEndProperty, newPosition); - _presenter.MoveCaretToTextPosition(newPosition); + ClearSelectionAndMoveCaretToTextPosition(direction > 0 ? + LogicalDirection.Forward : + LogicalDirection.Backward); } else { @@ -2100,6 +2106,17 @@ namespace Avalonia.Controls _scrollViewer?.PageDown(); } + private void ClearSelectionAndMoveCaretToTextPosition(LogicalDirection direction) + { + var newPosition = direction == LogicalDirection.Forward ? + Math.Max(SelectionStart, SelectionEnd) : + Math.Min(SelectionStart, SelectionEnd); + SetCurrentValue(SelectionStartProperty, newPosition); + SetCurrentValue(SelectionEndProperty, newPosition); + // move caret to appropriate side of previous selection + _presenter?.MoveCaretToTextPosition(newPosition); + } + /// /// Scroll the to the specified line index. /// diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 36a9c8cc68..81f3641329 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1540,6 +1540,7 @@ namespace Avalonia.Controls.UnitTests [InlineData(0,4)] [InlineData(2,6)] [InlineData(0,6)] + [InlineData(3,4)] public void When_Selection_From_Left_To_Right_Pressing_Right_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1568,6 +1569,7 @@ namespace Avalonia.Controls.UnitTests [InlineData(0,4)] [InlineData(2,6)] [InlineData(0,6)] + [InlineData(3,4)] public void When_Selection_From_Left_To_Right_Pressing_Left_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1596,6 +1598,7 @@ namespace Avalonia.Controls.UnitTests [InlineData(4,0)] [InlineData(6,2)] [InlineData(6,0)] + [InlineData(4,3)] public void When_Selection_From_Right_To_Left_Pressing_Right_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1624,6 +1627,7 @@ namespace Avalonia.Controls.UnitTests [InlineData(4,0)] [InlineData(6,2)] [InlineData(6,0)] + [InlineData(4,3)] public void When_Selection_From_Right_To_Left_Pressing_Left_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1701,6 +1705,176 @@ namespace Avalonia.Controls.UnitTests } } + [Theory] + [InlineData(2,4)] + [InlineData(0,4)] + [InlineData(2,6)] + [InlineData(0,6)] + [InlineData(3,4)] + public void When_Selection_From_Left_To_Right_Pressing_Up_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = selectionStart; + tb.SelectionStart = selectionStart; + tb.SelectionEnd = selectionEnd; + + RaiseKeyEvent(tb, Key.Up, KeyModifiers.None); + + Assert.Equal(selectionStart, tb.SelectionStart); + Assert.Equal(selectionStart, tb.SelectionEnd); + Assert.Equal(selectionStart, tb.CaretIndex); + } + } + + [Theory] + [InlineData(4,2)] + [InlineData(4,0)] + [InlineData(6,2)] + [InlineData(6,0)] + [InlineData(4,3)] + public void When_Selection_From_Right_To_Left_Pressing_Up_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = selectionStart; + tb.SelectionStart = selectionStart; + tb.SelectionEnd = selectionEnd; + + RaiseKeyEvent(tb, Key.Up, KeyModifiers.None); + + Assert.Equal(selectionEnd, tb.SelectionStart); + Assert.Equal(selectionEnd, tb.SelectionEnd); + Assert.Equal(selectionEnd, tb.CaretIndex); + } + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + [InlineData(4)] + [InlineData(6)] + public void When_Select_All_From_Position_Up_Should_Remove_Selection_Moving_Caret_To_Start(int caretIndex) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = caretIndex; + + RaiseKeyEvent(tb, Key.A, KeyModifiers.Control); + RaiseKeyEvent(tb, Key.Up, KeyModifiers.None); + + Assert.Equal(0, tb.SelectionStart); + Assert.Equal(0, tb.SelectionEnd); + Assert.Equal(0, tb.CaretIndex); + } + } + + [Theory] + [InlineData(2,4)] + [InlineData(0,4)] + [InlineData(2,6)] + [InlineData(0,6)] + [InlineData(3,4)] + public void When_Selection_From_Left_To_Right_Pressing_Down_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = selectionStart; + tb.SelectionStart = selectionStart; + tb.SelectionEnd = selectionEnd; + + RaiseKeyEvent(tb, Key.Down, KeyModifiers.None); + + Assert.Equal(selectionEnd, tb.SelectionStart); + Assert.Equal(selectionEnd, tb.SelectionEnd); + Assert.Equal(selectionEnd, tb.CaretIndex); + } + } + + [Theory] + [InlineData(4,2)] + [InlineData(4,0)] + [InlineData(6,2)] + [InlineData(6,0)] + [InlineData(4,3)] + public void When_Selection_From_Right_To_Left_Pressing_Down_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = selectionStart; + tb.SelectionStart = selectionStart; + tb.SelectionEnd = selectionEnd; + + RaiseKeyEvent(tb, Key.Down, KeyModifiers.None); + + Assert.Equal(selectionStart, tb.SelectionStart); + Assert.Equal(selectionStart, tb.SelectionEnd); + Assert.Equal(selectionStart, tb.CaretIndex); + } + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + [InlineData(4)] + [InlineData(6)] + public void When_Select_All_From_Position_Down_Should_Remove_Selection_Moving_Caret_To_End(int caretIndex) + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "ABCDEF" + }; + + tb.Measure(Size.Infinity); + tb.CaretIndex = caretIndex; + + RaiseKeyEvent(tb, Key.A, KeyModifiers.Control); + RaiseKeyEvent(tb, Key.Down, KeyModifiers.None); + + Assert.Equal(tb.Text.Length, tb.SelectionStart); + Assert.Equal(tb.Text.Length, tb.SelectionEnd); + Assert.Equal(tb.Text.Length, tb.CaretIndex); + } + } + [Fact] public void TextBox_In_AdornerLayer_Will_Not_Cause_Collection_Modified_In_VisualLayerManager_Measure() { From 82329882ac6b3487da6af2a3675810b80a819000 Mon Sep 17 00:00:00 2001 From: Maxwell Katz Date: Sun, 9 Mar 2025 00:13:30 +0900 Subject: [PATCH 04/12] Support complex types in datatype (#18379) * Fix XamlTypeExtensionNode not being handled on the x:DataType transformer * Add testt with complex DataType * Make vm:MainWindowViewModel+TestItem nested type generic on BindingDemo --- samples/BindingDemo/BindingDemo.csproj | 1 + samples/BindingDemo/MainWindow.xaml | 13 ++++--- samples/BindingDemo/MainWindow.xaml.cs | 2 +- samples/BindingDemo/TestItemView.xaml | 4 +- .../ViewModels/MainWindowViewModel.cs | 22 +++++------ ...valoniaXamlIlDataContextTypeTransformer.cs | 6 ++- .../CompiledBindingExtensionTests.cs | 38 +++++++++++++++++++ 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/samples/BindingDemo/BindingDemo.csproj b/samples/BindingDemo/BindingDemo.csproj index faeb643d8a..33c6aa1440 100644 --- a/samples/BindingDemo/BindingDemo.csproj +++ b/samples/BindingDemo/BindingDemo.csproj @@ -2,6 +2,7 @@ Exe $(AvsCurrentTargetFramework) + diff --git a/samples/BindingDemo/MainWindow.xaml b/samples/BindingDemo/MainWindow.xaml index ed23f68d91..9d68c8da8a 100644 --- a/samples/BindingDemo/MainWindow.xaml +++ b/samples/BindingDemo/MainWindow.xaml @@ -3,6 +3,7 @@ x:Class="BindingDemo.MainWindow" xmlns:vm="using:BindingDemo.ViewModels" xmlns:local="using:BindingDemo" + xmlns:system="clr-namespace:System;assembly=System.Runtime" Title="AvaloniaUI Bindings Test" Width="800" Height="600" @@ -29,7 +30,7 @@ - + @@ -51,9 +52,9 @@ + Text="{Binding Value, Source={StaticResource SharedItem}, Mode=TwoWay, DataType={x:Type vm:MainWindowViewModel+TestItem, x:TypeArguments=x:String}}"/> + Text="{Binding Value, Source={StaticResource SharedItem}, Mode=TwoWay, DataType={x:Type vm:MainWindowViewModel+TestItem, x:TypeArguments=x:String}}"/> @@ -67,8 +68,8 @@ - - + + @@ -81,7 +82,7 @@ - + diff --git a/samples/BindingDemo/MainWindow.xaml.cs b/samples/BindingDemo/MainWindow.xaml.cs index 57466fe581..36d4feb108 100644 --- a/samples/BindingDemo/MainWindow.xaml.cs +++ b/samples/BindingDemo/MainWindow.xaml.cs @@ -9,7 +9,7 @@ namespace BindingDemo { public MainWindow() { - Resources["SharedItem"] = new MainWindowViewModel.TestItem() { StringValue = "shared" }; + Resources["SharedItem"] = new MainWindowViewModel.TestItem() { Value = "shared" }; this.InitializeComponent(); this.DataContext = new MainWindowViewModel(); this.AttachDevTools(); diff --git a/samples/BindingDemo/TestItemView.xaml b/samples/BindingDemo/TestItemView.xaml index 6eb59ffc83..fcf3a80ceb 100644 --- a/samples/BindingDemo/TestItemView.xaml +++ b/samples/BindingDemo/TestItemView.xaml @@ -2,9 +2,9 @@ xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml' xmlns:viewModels="using:BindingDemo.ViewModels" x:Class="BindingDemo.TestItemView" - x:DataType="viewModels:MainWindowViewModel+TestItem"> + x:DataType="{x:Type viewModels:MainWindowViewModel+TestItem, x:TypeArguments=x:String}"> - + diff --git a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs index f5f4f3531f..eb1a007695 100644 --- a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs @@ -23,14 +23,14 @@ namespace BindingDemo.ViewModels public MainWindowViewModel() { - Items = new ObservableCollection( - Enumerable.Range(0, 20).Select(x => new TestItem + Items = new ObservableCollection>( + Enumerable.Range(0, 20).Select(x => new TestItem { - StringValue = "Item " + x, + Value = "Item " + x, Detail = "Item " + x + " details", })); - Selection = new SelectionModel { SingleSelect = false }; + Selection = new SelectionModel> { SingleSelect = false }; ShuffleItems = MiniCommand.Create(() => { @@ -58,8 +58,8 @@ namespace BindingDemo.ViewModels .Select(x => DateTimeOffset.Now); } - public ObservableCollection Items { get; } - public SelectionModel Selection { get; } + public ObservableCollection> Items { get; } + public SelectionModel> Selection { get; } public MiniCommand ShuffleItems { get; } public string BooleanString @@ -117,15 +117,15 @@ namespace BindingDemo.ViewModels } // Nested class, jsut so we can test it in XAML - public class TestItem : ViewModelBase + public class TestItem : ViewModelBase { - private string _stringValue = "String Value"; + private T _value; private string _detail; - public string StringValue + public T Value { - get { return _stringValue; } - set { this.RaiseAndSetIfChanged(ref this._stringValue, value); } + get { return _value; } + set { this.RaiseAndSetIfChanged(ref this._value, value); } } public string Detail diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlDataContextTypeTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlDataContextTypeTransformer.cs index 6b4f32bce0..8632ea6ee0 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlDataContextTypeTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlDataContextTypeTransformer.cs @@ -44,7 +44,11 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers { on.Children.RemoveAt(i); i--; - if (directive.Values[0] is XamlAstTextNode text) + if (directive.Values[0] is XamlTypeExtensionNode typeNode) + { + directiveDataContextTypeNode = new AvaloniaXamlIlDataContextTypeMetadataNode(on, typeNode.Value.GetClrType()); + } + else if (directive.Values[0] is XamlAstTextNode text) { directiveDataContextTypeNode = new AvaloniaXamlIlDataContextTypeMetadataNode(on, TypeReferenceResolver.ResolveType(context, text.Text, isMarkupExtension: false, text, strict: true).Type); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs index 327f06f5eb..069c3a3426 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs @@ -2369,6 +2369,37 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions } } + [Fact] + public void Resolves_Nested_Generic_DataTypes() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + + + +"); + var textBlock = window.GetControl("textBlock"); + + var dataContext = new TestDataContext + { + NestedGenericString = new TestDataContext.NestedGeneric + { + Value = "10" + } + }; + + window.DataContext = dataContext.NestedGenericString; + + Assert.Equal(dataContext.NestedGenericString.Value, textBlock.Text); + } + } + static void Throws(string type, Action cb) { try @@ -2459,6 +2490,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions public INonIntegerIndexerDerived NonIntegerIndexerInterfaceProperty => NonIntegerIndexerProperty; + public NestedGeneric? NestedGenericString { get; init; } + string IHasExplicitProperty.ExplicitProperty => "Hello"; public string ExplicitProperty => "Bye"; @@ -2484,6 +2517,11 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions } } } + + public class NestedGeneric + { + public T Value { get; set; } + } } public class ListItemCollectionView : List From edaaf5bbda2320d4392bd5d76c1c8cc519727c07 Mon Sep 17 00:00:00 2001 From: Vadim Kutin Date: Sat, 8 Mar 2025 16:44:14 +0100 Subject: [PATCH 05/12] Improve KeyGesture.ToString() output in case when Key is set to Key.None (#18353) * Improve ToString() output in case when Key is set to Key.None * Update KeyGesture.Parse method to support only modifiers combinations * Update tests with empty combination and modifiers-only combinations --- src/Avalonia.Base/Input/KeyGesture.cs | 26 ++++++++++--------- .../Input/KeyGestureTests.cs | 5 ++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Base/Input/KeyGesture.cs b/src/Avalonia.Base/Input/KeyGesture.cs index 122176a127..83d99bf7a9 100644 --- a/src/Avalonia.Base/Input/KeyGesture.cs +++ b/src/Avalonia.Base/Input/KeyGesture.cs @@ -79,15 +79,10 @@ namespace Avalonia.Input { var partSpan = gesture.AsSpan(cstart, c - cstart).Trim(); - if (isLast) - { - key = ParseKey(partSpan.ToString()); - } - else + if (!TryParseKey(partSpan.ToString(), out key)) { keyModifiers |= ParseModifier(partSpan); } - cstart = c + 1; } } @@ -151,8 +146,11 @@ namespace Avalonia.Input s.Append(formatInfo.Meta); } - Plus(s); - s.Append(formatInfo.FormatKey(Key)); + if ((Key != Key.None) || (KeyModifiers == KeyModifiers.None)) + { + Plus(s); + s.Append(formatInfo.FormatKey(Key)); + } return StringBuilderCache.GetStringAndRelease(s); } @@ -163,12 +161,16 @@ namespace Avalonia.Input ResolveNumPadOperationKey(keyEvent.Key) == ResolveNumPadOperationKey(Key); // TODO: Move that to external key parser - private static Key ParseKey(string key) + private static bool TryParseKey(string keyStr, out Key key) { - if (s_keySynonyms.TryGetValue(key.ToLower(CultureInfo.InvariantCulture), out Key rv)) - return rv; + key = Key.None; + if (s_keySynonyms.TryGetValue(keyStr.ToLower(CultureInfo.InvariantCulture), out key)) + return true; + + if (EnumHelper.TryParse(keyStr, true, out key)) + return true; - return EnumHelper.Parse(key, true); + return false; } private static KeyModifiers ParseModifier(ReadOnlySpan modifier) diff --git a/tests/Avalonia.Base.UnitTests/Input/KeyGestureTests.cs b/tests/Avalonia.Base.UnitTests/Input/KeyGestureTests.cs index 33cd94ec98..790d1f9c49 100644 --- a/tests/Avalonia.Base.UnitTests/Input/KeyGestureTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/KeyGestureTests.cs @@ -13,6 +13,9 @@ namespace Avalonia.Base.UnitTests.Input new object[]{"Control++", new KeyGesture(Key.OemPlus, KeyModifiers.Control) }, new object[]{ "Shift+⌘+A", new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift) }, new object[]{ "Shift+Cmd+A", new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift) }, + new object[]{"None", new KeyGesture(Key.None)}, + new object[]{"Alt+Shift", new KeyGesture(Key.None, KeyModifiers.Alt | KeyModifiers.Shift)}, + }; public static readonly IEnumerable ToStringData = new object[][] @@ -23,6 +26,8 @@ namespace Avalonia.Base.UnitTests.Input new object[]{new KeyGesture(Key.A, KeyModifiers.Alt | KeyModifiers.Shift), "Shift+Alt+A"}, new object[]{new KeyGesture(Key.A, KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift), "Ctrl+Shift+Alt+A"}, new object[]{new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift), "Shift+Cmd+A"}, + new object[]{new KeyGesture(Key.None), "None"}, + new object[]{new KeyGesture(Key.None, KeyModifiers.Alt | KeyModifiers.Shift), "Shift+Alt"}, }; [Theory] From d55421bdc9d738393c33f61ea2af10ab3d179956 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Sun, 9 Mar 2025 11:15:29 +0100 Subject: [PATCH 06/12] Introduce GlyphTypeface.FaceNames (#18392) Co-authored-by: Julien Lebosquain --- .../Media/Fonts/Tables/Name/NameTable.cs | 48 +++++-------------- src/Avalonia.Base/Media/IGlyphTypeface2.cs | 6 +++ src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs | 35 ++++++++++++-- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs index 9a4b664f47..c0c1048e51 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs @@ -2,26 +2,25 @@ // Licensed under the Apache License, Version 2.0. // Ported from: https://github.com/SixLabors/Fonts/blob/034a440aece357341fcc6b02db58ffbe153e54ef/src/SixLabors.Fonts +using System.Collections; using System.Collections.Generic; using System.IO; +using Avalonia.Utilities; namespace Avalonia.Media.Fonts.Tables.Name { - internal class NameTable + internal class NameTable : IEnumerable { internal const string TableName = "name"; internal static readonly OpenTypeTag Tag = OpenTypeTag.Parse(TableName); private readonly NameRecord[] _names; - internal NameTable(NameRecord[] names, IReadOnlyList languages) + internal NameTable(NameRecord[] names) { _names = names; - Languages = languages; } - public IReadOnlyList Languages { get; } - /// /// Gets the name of the font. /// @@ -133,22 +132,6 @@ namespace Avalonia.Media.Fonts.Tables.Name } } - //var languageNames = Array.Empty(); - - //if (format == 1) - //{ - // // Format 1 adds language data. - // var langCount = reader.ReadUInt16(); - // languageNames = new StringLoader[langCount]; - - // for (var i = 0; i < langCount; i++) - // { - // languageNames[i] = StringLoader.Create(reader); - - // strings.Add(languageNames[i]); - // } - //} - foreach (var readable in strings) { var readableStartOffset = stringOffset + readable.Offset; @@ -158,22 +141,17 @@ namespace Avalonia.Media.Fonts.Tables.Name readable.LoadValue(reader); } - var cultures = new List(); - - foreach (var nameRecord in names) - { - if (nameRecord.NameID != KnownNameIds.FontFamilyName || nameRecord.Platform != PlatformIDs.Windows || nameRecord.LanguageID == 0) - { - continue; - } + return new NameTable(names); + } - if (!cultures.Contains(nameRecord.LanguageID)) - { - cultures.Add(nameRecord.LanguageID); - } - } + public IEnumerator GetEnumerator() + { + return new ImmutableReadOnlyListStructEnumerator(_names); + } - return new NameTable(names, cultures); + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); } } } diff --git a/src/Avalonia.Base/Media/IGlyphTypeface2.cs b/src/Avalonia.Base/Media/IGlyphTypeface2.cs index 84b892dae3..3bd2b1e767 100644 --- a/src/Avalonia.Base/Media/IGlyphTypeface2.cs +++ b/src/Avalonia.Base/Media/IGlyphTypeface2.cs @@ -29,5 +29,11 @@ namespace Avalonia.Media /// Gets supported font features. /// IReadOnlyList SupportedFeatures { get; } + + /// + /// Gets the localized face names. + /// Keys are culture identifiers. + /// + IReadOnlyDictionary FaceNames { get; } } } diff --git a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs index 9170393034..2def64c18d 100644 --- a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs @@ -111,18 +111,45 @@ namespace Avalonia.Skia if(_nameTable != null) { - var familyNames = new Dictionary(_nameTable.Languages.Count); + var familyNames = new Dictionary(1); + var faceNames = new Dictionary(1); - foreach (var language in _nameTable.Languages) + foreach (var nameRecord in _nameTable) { - familyNames.Add(language, _nameTable.FontFamilyName(language)); + if(nameRecord.NameID == KnownNameIds.FontFamilyName) + { + if (nameRecord.Platform != PlatformIDs.Windows || nameRecord.LanguageID == 0) + { + continue; + } + + if (!familyNames.ContainsKey(nameRecord.LanguageID)) + { + familyNames[nameRecord.LanguageID] = nameRecord.Value; + } + } + + if(nameRecord.NameID == KnownNameIds.FontSubfamilyName) + { + if (nameRecord.Platform != PlatformIDs.Windows || nameRecord.LanguageID == 0) + { + continue; + } + + if (!faceNames.ContainsKey(nameRecord.LanguageID)) + { + faceNames[nameRecord.LanguageID] = nameRecord.Value; + } + } } FamilyNames = familyNames; + FaceNames = faceNames; } else { FamilyNames = new Dictionary { { (ushort)CultureInfo.InvariantCulture.LCID, FamilyName } }; + FaceNames = new Dictionary { { (ushort)CultureInfo.InvariantCulture.LCID, Weight.ToString() } }; } } @@ -130,6 +157,8 @@ namespace Avalonia.Skia public IReadOnlyDictionary FamilyNames { get; } + public IReadOnlyDictionary FaceNames { get; } + public IReadOnlyList SupportedFeatures { get From 156f587cfadd503505079d9518f89184ea1769dd Mon Sep 17 00:00:00 2001 From: Johan Polson <40406620+johanpolson@users.noreply.github.com> Date: Sun, 9 Mar 2025 11:46:09 +0100 Subject: [PATCH 07/12] Use "handled" for keybord input in Browser (#18349) Co-authored-by: Julien Lebosquain --- .../Avalonia.Browser/Interop/InputHelper.cs | 15 +++++++++++---- .../webapp/modules/avalonia/input.ts | 19 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs index 29ddb36e9e..088c1d4a9c 100644 --- a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs @@ -13,16 +13,23 @@ internal static partial class InputHelper return Task.CompletedTask; } + public static Task RedirectInputRetunAsync(int topLevelId, Func handler, T @default) + { + if (BrowserTopLevelImpl.TryGetTopLevel(topLevelId) is { } topLevelImpl) + return Task.FromResult(handler(topLevelImpl)); + return Task.FromResult(@default); + } + [JSImport("InputHelper.subscribeInputEvents", AvaloniaModule.MainModuleName)] public static partial void SubscribeInputEvents(JSObject htmlElement, int topLevelId); [JSExport] - public static Task OnKeyDown(int topLevelId, string code, string key, int modifier) => - RedirectInputAsync(topLevelId, t => t.InputHandler.OnKeyDown(code, key, modifier)); + public static Task OnKeyDown(int topLevelId, string code, string key, int modifier) => + RedirectInputRetunAsync(topLevelId, t => t.InputHandler.OnKeyDown(code, key, modifier), false); [JSExport] - public static Task OnKeyUp(int topLevelId, string code, string key, int modifier) => - RedirectInputAsync(topLevelId, t => t.InputHandler.OnKeyUp(code, key, modifier)); + public static Task OnKeyUp(int topLevelId, string code, string key, int modifier) => + RedirectInputRetunAsync(topLevelId, t => t.InputHandler.OnKeyUp(code, key, modifier), false); [JSExport] public static Task OnBeforeInput(int topLevelId, string inputType, int start, int end) => diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts index 5d14597642..704028271d 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts @@ -95,16 +95,23 @@ export class InputHelper { public static subscribeKeyEvents(element: HTMLInputElement, topLevelId: number) { const keyDownHandler = (args: KeyboardEvent) => { - JsExports.InputHelper.OnKeyDown(topLevelId, args.code, args.key, this.getModifiers(args)); - if (this.clipboardState !== ClipboardState.Pending) { - args.preventDefault(); - } + JsExports.InputHelper.OnKeyDown(topLevelId, args.code, args.key, this.getModifiers(args)) + .then((handled: boolean) => { + if (!handled || this.clipboardState !== ClipboardState.Pending) { + args.preventDefault(); + } + }); }; element.addEventListener("keydown", keyDownHandler); const keyUpHandler = (args: KeyboardEvent) => { - JsExports.InputHelper.OnKeyUp(topLevelId, args.code, args.key, this.getModifiers(args)); - args.preventDefault(); + JsExports.InputHelper.OnKeyUp(topLevelId, args.code, args.key, this.getModifiers(args)) + .then((handled: boolean) => { + if (!handled) { + args.preventDefault(); + } + }); + if (this.rejectClipboard) { this.rejectClipboard(); } From ce72eced6b62845228d1ee291c51b80a1b80c36d Mon Sep 17 00:00:00 2001 From: StefanKoell Date: Sun, 9 Mar 2025 21:46:32 +0100 Subject: [PATCH 08/12] Implement SetItems method in ResourceDictionary (#18354) * Implement AddOrUpdateRange method in ResourceDictionary * Change to SetItems method nam, remove flag --------- Co-authored-by: Julien Lebosquain --- src/Avalonia.Base/Controls/ResourceDictionary.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Controls/ResourceDictionary.cs b/src/Avalonia.Base/Controls/ResourceDictionary.cs index 748882a450..ded0a815a5 100644 --- a/src/Avalonia.Base/Controls/ResourceDictionary.cs +++ b/src/Avalonia.Base/Controls/ResourceDictionary.cs @@ -40,7 +40,8 @@ namespace Avalonia.Controls set { Inner[key] = value; - Owner?.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); + Owner?.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); + } } @@ -150,6 +151,19 @@ namespace Avalonia.Controls public void AddNotSharedDeferred(object key, IDeferredContent deferredContent) => Add(key, new NotSharedDeferredItem(deferredContent)); + public void SetItems(IEnumerable> values) + { + try + { + foreach (var value in values) + Inner[value.Key] = value.Value; + } + finally + { + Owner?.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); + } + } + public void Clear() { if (_inner?.Count > 0) From c416fc716d6e853ddce69307a5bf4b2f7d8415c3 Mon Sep 17 00:00:00 2001 From: Dong Bin <14807942+rabbitism@users.noreply.github.com> Date: Tue, 11 Mar 2025 16:28:30 +0800 Subject: [PATCH 09/12] fix: InputMethod should remove handler. (#18414) --- src/Avalonia.Base/Input/InputMethod.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Input/InputMethod.cs b/src/Avalonia.Base/Input/InputMethod.cs index 9df48a7d2e..b8b6c564a9 100644 --- a/src/Avalonia.Base/Input/InputMethod.cs +++ b/src/Avalonia.Base/Input/InputMethod.cs @@ -43,7 +43,7 @@ namespace Avalonia.Input public static void RemoveTextInputMethodClientRequeryRequestedHandler(Interactive element, EventHandler handler) { - element.AddHandler(TextInputMethodClientRequeryRequestedEvent, handler); + element.RemoveHandler(TextInputMethodClientRequeryRequestedEvent, handler); } private InputMethod() From 6a010a89f2d13adba0a6cb3433f47ebc76eb467f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Krysi=C5=84ski?= Date: Tue, 11 Mar 2025 09:33:48 +0100 Subject: [PATCH 10/12] Added surface dispose to DrawingSurfaceDemoBase (#18412) --- samples/GpuInterop/DrawingSurfaceDemoBase.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/samples/GpuInterop/DrawingSurfaceDemoBase.cs b/samples/GpuInterop/DrawingSurfaceDemoBase.cs index be5d920b89..b076f5b489 100644 --- a/samples/GpuInterop/DrawingSurfaceDemoBase.cs +++ b/samples/GpuInterop/DrawingSurfaceDemoBase.cs @@ -33,7 +33,11 @@ public abstract class DrawingSurfaceDemoBase : Control, IGpuDemo protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) { if (_initialized) + { + Surface?.Dispose(); FreeGraphicsResources(); + } + _initialized = false; base.OnDetachedFromLogicalTree(e); } From e189ef9c53e887d44f495ff9e0573195e0427bcd Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Tue, 11 Mar 2025 19:02:30 -0400 Subject: [PATCH 11/12] Fix - Search for visual parents when hittesting for pointer events (#18416) * search for visual parents when hittesting * Add unit test for hit testing on disabled visual --------- Co-authored-by: Julien Lebosquain --- src/Avalonia.Controls/TopLevel.cs | 2 +- .../Input/PointerOverTests.cs | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index fa03cc7be8..3a71b32685 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -866,7 +866,7 @@ namespace Avalonia.Controls var candidate = hitTestElement; while (candidate?.IsEffectivelyEnabled == false) { - candidate = (candidate as Visual)?.Parent as IInputElement; + candidate = (candidate as Visual)?.VisualParent as IInputElement; } return candidate; diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs index 45277ca75f..4f9531cd63 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs @@ -6,6 +6,7 @@ using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Input.Raw; +using Avalonia.Media; using Avalonia.Rendering; using Avalonia.UnitTests; @@ -494,6 +495,60 @@ namespace Avalonia.Base.UnitTests.Input result); } + [Fact] + public void Disabled_Element_Should_Set_PointerOver_On_Visual_Parent() + { + using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); + + var renderer = new Mock(); + var deviceMock = CreatePointerDeviceMock(); + var impl = CreateTopLevelImplMock(); + + var disabledChild = new Border + { + Background = Brushes.Red, + Width = 100, + Height = 100, + IsEnabled = false + }; + + var visualParent = new Border + { + Background = Brushes.Black, + Width = 100, + Height = 100, + Child = disabledChild + }; + + var logicalParent = new Border + { + Background = Brushes.Blue, + Width = 100, + Height = 100 + }; + + // Change the logical parent and check that we're correctly hit testing on the visual tree. + // This scenario is made up because it's easy to test. + // In the real world, this happens with nested Popups from MenuItems (but that's very cumbersome to test). + ((ISetLogicalParent) disabledChild).SetParent(null); + ((ISetLogicalParent) disabledChild).SetParent(logicalParent); + + var root = CreateInputRoot( + impl.Object, + new Panel + { + Children = { visualParent } + }, + renderer.Object); + + Assert.False(visualParent.IsPointerOver); + SetHit(renderer, disabledChild); + + impl.Object.Input!(CreateRawPointerMovedArgs(deviceMock.Object, root, new Point(50, 50))); + Assert.True(visualParent.IsPointerOver); + Assert.False(logicalParent.IsPointerOver); + } + private static void AddEnteredExitedHandlers( EventHandler handler, params IInputElement[] controls) From 3c7c4690181e3e5f79b32c2e5fefde91b801abbd Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 12 Mar 2025 03:01:01 +0100 Subject: [PATCH 12/12] Raise pointer events on captured element (#18420) --- .../Input/PointerOverPreProcessor.cs | 14 ++++++++++++-- .../Input/PointerOverTests.cs | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs index b989def335..e79e0c3591 100644 --- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs +++ b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs @@ -79,7 +79,9 @@ namespace Avalonia.Input else if (pointerDevice.TryGetPointer(args) is { } pointer && pointer.Type != PointerType.Touch) { - var element = pointer.Captured ?? args.InputHitTestResult.firstEnabledAncestor; + var element = GetEffectivePointerOverElement( + args.InputHitTestResult.firstEnabledAncestor, + pointer.Captured); SetPointerOver(pointer, args.Root, element, args.Timestamp, args.Position, new PointerPointProperties(args.InputModifiers, args.Type.ToUpdateKind()), @@ -96,7 +98,10 @@ namespace Avalonia.Input if (dirtyRect.Contains(clientPoint)) { - var element = pointer.Captured ?? _inputRoot.InputHitTest(clientPoint); + var element = GetEffectivePointerOverElement( + _inputRoot.InputHitTest(clientPoint), + pointer.Captured); + SetPointerOver(pointer, _inputRoot, element, 0, clientPoint, PointerPointProperties.None, KeyModifiers.None); } else if (!((Visual)_inputRoot).Bounds.Contains(clientPoint)) @@ -106,6 +111,11 @@ namespace Avalonia.Input } } + private static IInputElement? GetEffectivePointerOverElement(IInputElement? hitTestElement, IInputElement? captured) + => captured is not null && hitTestElement != captured ? + null : + hitTestElement; + private void ClearPointerOver() { if (_currentPointer is (var pointer, var position)) diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs index 4f9531cd63..0c1e790bac 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs @@ -120,7 +120,7 @@ namespace Avalonia.Base.UnitTests.Input } [Fact] - public void HitTest_Should_Be_Ignored_If_Element_Captured() + public void HitTest_Should_Ignore_Non_Captured_Elements() { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); @@ -145,8 +145,19 @@ namespace Avalonia.Base.UnitTests.Input } }, renderer.Object); - SetHit(renderer, canvas); pointer.SetupGet(p => p.Captured).Returns(decorator); + + // Move the pointer over the canvas: the captured decorator should lose the pointer over state. + SetHit(renderer, canvas); + impl.Object.Input!(CreateRawPointerMovedArgs(device, root)); + + Assert.False(decorator.IsPointerOver); + Assert.False(border.IsPointerOver); + Assert.False(canvas.IsPointerOver); + Assert.False(root.IsPointerOver); + + // Move back the pointer over the decorator: raise events normally for it since it's captured. + SetHit(renderer, decorator); impl.Object.Input!(CreateRawPointerMovedArgs(device, root)); Assert.True(decorator.IsPointerOver);