From 65544ed9a4fb7f6c45d574707dc688defe582f6f Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 7 Aug 2025 22:38:17 +0200 Subject: [PATCH 001/154] Update release instructions (#19417) --- docs/release.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release.md b/docs/release.md index 00c83cc47c..42f8927f42 100644 --- a/docs/release.md +++ b/docs/release.md @@ -16,8 +16,10 @@ This document describes the process for creating a new Avalonia release - Create a branch named e.g. `release/11.0.9` for the specific minor version - Update the version number in the file [SharedVersion.props](../build/SharedVersion.props), e.g. `11.0.9` -- Add a tag for this version, e.g. `git tag 11.0.9` -- Push the release branch and the tag. +- Commit the file. +- Add a tag for this version, e.g. `git tag 11.0.9`. +- Update the `release/latest` branch to point to the same commit. +- Push the release branches and the tag. - Wait for azure pipelines to finish the build. Nightly build with 11.0.9 version should be released soon after. - Using the nightly build run a due diligence test to make sure you're happy with the package. - On azure pipelines, on the release for your release branch `release/11.0.9` click on the badge for "Nuget Release" @@ -27,4 +29,4 @@ This document describes the process for creating a new Avalonia release - Replace changelog with one generated by avalonia-backport tool. Enable discussion for the specific release - Review the release information and publish. - Update the dotnet templates, visual studio templates. -- Announce on telegram (RU and EN), twitter, etc \ No newline at end of file +- Announce on telegram (RU and EN), twitter, etc From 0566fa861d7f7913856771890decdeb8174c26db Mon Sep 17 00:00:00 2001 From: Grigorii Vasilchenko <36913490+11v1@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:44:25 +0700 Subject: [PATCH 002/154] WeakEvent subscription management thread race condition fix (#19383) * Fixed thread-race condition when INotifyCollectionChanged event fired on non-ui thread * Fixed WeakEvent thread-race condition (NullReferenceException) when event fired on non-ui thread * Limited delegates creation in WeakEvent * Avoid casting in WeakEvent --- src/Avalonia.Base/Utilities/WeakEvent.cs | 111 +++++++++++++----- .../Utils/CollectionChangedEventManager.cs | 12 +- 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/src/Avalonia.Base/Utilities/WeakEvent.cs b/src/Avalonia.Base/Utilities/WeakEvent.cs index bc7cd04568..a237143c6c 100644 --- a/src/Avalonia.Base/Utilities/WeakEvent.cs +++ b/src/Avalonia.Base/Utilities/WeakEvent.cs @@ -1,5 +1,7 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; +using Avalonia.Collections.Pooled; using Avalonia.Threading; namespace Avalonia.Utilities; @@ -10,8 +12,8 @@ namespace Avalonia.Utilities; public sealed class WeakEvent : WeakEvent where TSender : class { private readonly Func, Action> _subscribe; - - private readonly ConditionalWeakTable _subscriptions = new(); + private readonly ConditionalWeakTable _subscriptions = new(); + private readonly ConditionalWeakTable.CreateValueCallback _createSubscription; internal WeakEvent( Action> subscribe, @@ -22,33 +24,43 @@ public sealed class WeakEvent : WeakEvent where TSender : c subscribe(t, s); return () => unsubscribe(t, s); }; + _createSubscription = CreateSubscription; } - + internal WeakEvent(Func, Action> subscribe) { _subscribe = subscribe; + _createSubscription = CreateSubscription; } - + public void Subscribe(TSender target, IWeakEventSubscriber subscriber) { - if (!_subscriptions.TryGetValue(target, out var subscription)) - _subscriptions.Add(target, subscription = new Subscription(this, target)); - subscription.Add(subscriber); + var spinWait = default(SpinWait); + while (true) + { + var subscription = _subscriptions.GetValue(target, _createSubscription); + if (subscription.Add(subscriber)) + break; + spinWait.SpinOnce(); + } } public void Unsubscribe(TSender target, IWeakEventSubscriber subscriber) { - if (_subscriptions.TryGetValue(target, out var subscription)) + if (_subscriptions.TryGetValue(target, out var subscription)) subscription.Remove(subscriber); } + private Subscription CreateSubscription(TSender key) => new(this, key); + private sealed class Subscription { private readonly WeakEvent _ev; private readonly TSender _target; private readonly Action _compact; - private readonly Action _unsubscribe; private readonly WeakHashList> _list = new(); + private readonly object _lock = new(); + private Action? _unsubscribe; private bool _compactScheduled; private bool _destroyed; @@ -57,7 +69,6 @@ public sealed class WeakEvent : WeakEvent where TSender : c _ev = ev; _target = target; _compact = Compact; - _unsubscribe = ev._subscribe(target, OnEvent); } private void Destroy() @@ -65,19 +76,42 @@ public sealed class WeakEvent : WeakEvent where TSender : c if(_destroyed) return; _destroyed = true; - _unsubscribe(); + _unsubscribe?.Invoke(); _ev._subscriptions.Remove(_target); } - public void Add(IWeakEventSubscriber s) => _list.Add(s); + public bool Add(IWeakEventSubscriber s) + { + if (_destroyed) + return false; + + lock (_lock) + { + if (_destroyed) + return false; + + _unsubscribe ??= _ev._subscribe(_target, OnEvent); + _list.Add(s); + return true; + } + } public void Remove(IWeakEventSubscriber s) { - _list.Remove(s); - if(_list.IsEmpty) - Destroy(); - else if(_list.NeedCompact && _compactScheduled) - ScheduleCompact(); + if (_destroyed) + return; + + lock (_lock) + { + if (_destroyed) + return; + + _list.Remove(s); + if(_list.IsEmpty) + Destroy(); + else if(_list.NeedCompact && _compactScheduled) + ScheduleCompact(); + } } private void ScheduleCompact() @@ -90,23 +124,40 @@ public sealed class WeakEvent : WeakEvent where TSender : c private void Compact() { - if(!_compactScheduled) + if (_destroyed) return; - _compactScheduled = false; - _list.Compact(); - if (_list.IsEmpty) - Destroy(); + + lock (_lock) + { + if (_destroyed) + return; + if(!_compactScheduled) + return; + _compactScheduled = false; + _list.Compact(); + if (_list.IsEmpty) + Destroy(); + } } private void OnEvent(object? sender, TEventArgs eventArgs) { - var alive = _list.GetAlive(); - if(alive == null) - Destroy(); - else + PooledList>? alive; + lock (_lock) + { + alive = _list.GetAlive(); + if (alive == null) + { + Destroy(); + return; + } + } + + foreach(var item in alive.Span) + item.OnEvent(_target, _ev, eventArgs); + + lock (_lock) { - foreach(var item in alive.Span) - item.OnEvent(_target, _ev, eventArgs); WeakHashList>.ReturnToSharedPool(alive); if(_list.NeedCompact && !_compactScheduled) ScheduleCompact(); @@ -124,13 +175,13 @@ public class WeakEvent { return new WeakEvent(subscribe, unsubscribe); } - + public static WeakEvent Register( Func, Action> subscribe) where TSender : class where TEventArgs : EventArgs { return new WeakEvent(subscribe); } - + public static WeakEvent Register( Action subscribe, Action unsubscribe) where TSender : class diff --git a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs index 675e9e13e5..d9fe30b72b 100644 --- a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs +++ b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs @@ -132,18 +132,22 @@ namespace Avalonia.Controls.Utils } } - var l = Listeners.ToArray(); - if (Dispatcher.UIThread.CheckAccess()) { + var l = Listeners.ToArray(); Notify(_collection, e, l); } else { var eCapture = e; - Dispatcher.UIThread.Post(() => Notify(_collection, eCapture, l), DispatcherPriority.Send); + Dispatcher.UIThread.Post(() => + { + var l = Listeners.ToArray(); + Notify(_collection, eCapture, l); + }, + DispatcherPriority.Send); } } } } -} +} \ No newline at end of file From 995f8eda5d2302e5375d63a77f1704970fb08f55 Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:52:18 +0200 Subject: [PATCH 003/154] Removed all nullability overrides from TimePickerPresenter and DatePickerPresenter (#19241) Fixed obscure cases where NullReferenceException could be thrown if a template hasn't been applied yet, or where it provides only some optional items Replaced repeated string literals with shared const values Relaxed template part requirements: RepeatButton to Button, Rectancle to Control --- .../DateTimePickers/DatePickerPresenter.cs | 357 ++++++++-------- .../DateTimePickers/TimePickerPresenter.cs | 382 ++++++++++-------- 2 files changed, 404 insertions(+), 335 deletions(-) diff --git a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs index b745ed7779..bfe928309f 100644 --- a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs +++ b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs @@ -1,6 +1,5 @@ using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; -using Avalonia.Controls.Shapes; using Avalonia.Input; using Avalonia.Interactivity; using System; @@ -13,23 +12,23 @@ namespace Avalonia.Controls /// Defines the presenter used for selecting a date for a /// /// - [TemplatePart("PART_AcceptButton", typeof(Button), IsRequired = true)] - [TemplatePart("PART_DayDownButton", typeof(RepeatButton))] - [TemplatePart("PART_DayHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_DaySelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_DayUpButton", typeof(RepeatButton))] - [TemplatePart("PART_DismissButton", typeof(Button))] - [TemplatePart("PART_FirstSpacer", typeof(Rectangle))] - [TemplatePart("PART_MonthDownButton", typeof(RepeatButton))] - [TemplatePart("PART_MonthHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_MonthSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_MonthUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PickerContainer", typeof(Grid), IsRequired = true)] - [TemplatePart("PART_SecondSpacer", typeof(Rectangle))] - [TemplatePart("PART_YearDownButton", typeof(RepeatButton))] - [TemplatePart("PART_YearHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_YearSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_YearUpButton", typeof(RepeatButton))] + [TemplatePart(TemplateItems.AcceptButtonName, typeof(Button), IsRequired = true)] + [TemplatePart(TemplateItems.DayDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.DayHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.DaySelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.DayUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.DismissButtonName, typeof(Button))] + [TemplatePart(TemplateItems.FirstSpacerName, typeof(Control))] + [TemplatePart(TemplateItems.MonthDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MonthHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.MonthSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.MonthUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PickerContainerName, typeof(Grid), IsRequired = true)] + [TemplatePart(TemplateItems.SecondSpacerName, typeof(Control))] + [TemplatePart(TemplateItems.YearDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.YearHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.YearSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.YearUpButtonName, typeof(Button))] public class DatePickerPresenter : PickerPresenterBase { /// @@ -102,24 +101,61 @@ namespace Avalonia.Controls public static readonly StyledProperty YearVisibleProperty = DatePicker.YearVisibleProperty.AddOwner(); - // Template Items - private Grid? _pickerContainer; - private Button? _acceptButton; - private Button? _dismissButton; - private Rectangle? _spacer1; - private Rectangle? _spacer2; - private Panel? _monthHost; - private Panel? _yearHost; - private Panel? _dayHost; - private DateTimePickerPanel? _monthSelector; - private DateTimePickerPanel? _yearSelector; - private DateTimePickerPanel? _daySelector; - private Button? _monthUpButton; - private Button? _dayUpButton; - private Button? _yearUpButton; - private Button? _monthDownButton; - private Button? _dayDownButton; - private Button? _yearDownButton; + private struct TemplateItems + { + public Grid _pickerContainer; + public const string PickerContainerName = "PART_PickerContainer"; + + public Button _acceptButton; + public const string AcceptButtonName = "PART_AcceptButton"; + + public Button? _dismissButton; + public const string DismissButtonName = "PART_DismissButton"; + + public Control? _firstSpacer; + public const string FirstSpacerName = "PART_FirstSpacer"; + + public Control? _secondSpacer; + public const string SecondSpacerName = "PART_SecondSpacer"; + + public Panel _monthHost; + public const string MonthHostName = "PART_MonthHost"; + + public Panel _yearHost; + public const string YearHostName = "PART_YearHost"; + + public Panel _dayHost; + public const string DayHostName = "PART_DayHost"; + + public DateTimePickerPanel _monthSelector; + public const string MonthSelectorName = "PART_MonthSelector"; + + public DateTimePickerPanel _yearSelector; + public const string YearSelectorName = "PART_YearSelector"; + + public DateTimePickerPanel _daySelector; + public const string DaySelectorName = "PART_DaySelector"; + + public Button? _monthUpButton; + public const string MonthUpButtonName = "PART_MonthUpButton"; + + public Button? _dayUpButton; + public const string DayUpButtonName = "PART_DayUpButton"; + + public Button? _yearUpButton; + public const string YearUpButtonName = "PART_YearUpButton"; + + public Button? _monthDownButton; + public const string MonthDownButtonName = "PART_MonthDownButton"; + + public Button? _dayDownButton; + public const string DayDownButtonName = "PART_DayDownButton"; + + public Button? _yearDownButton; + public const string YearDownButtonName = "PART_YearDownButton"; + } + + private TemplateItems? _templateItems; private DateTimeOffset _syncDate; @@ -235,69 +271,54 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - // These are requirements, so throw if not found - _pickerContainer = e.NameScope.Get("PART_PickerContainer"); - _monthHost = e.NameScope.Get("PART_MonthHost"); - _dayHost = e.NameScope.Get("PART_DayHost"); - _yearHost = e.NameScope.Get("PART_YearHost"); - _monthSelector = e.NameScope.Get("PART_MonthSelector"); - _monthSelector.SelectionChanged += OnMonthChanged; - - _daySelector = e.NameScope.Get("PART_DaySelector"); - _daySelector.SelectionChanged += OnDayChanged; - - _yearSelector = e.NameScope.Get("PART_YearSelector"); - _yearSelector.SelectionChanged += OnYearChanged; - - _acceptButton = e.NameScope.Get - [TemplatePart("PART_AcceptButton", typeof(Button), IsRequired = true)] - [TemplatePart("PART_DismissButton", typeof(Button))] - [TemplatePart("PART_HourDownButton", typeof(RepeatButton))] - [TemplatePart("PART_HourSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_HourUpButton", typeof(RepeatButton))] - [TemplatePart("PART_MinuteDownButton", typeof(RepeatButton))] - [TemplatePart("PART_MinuteSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_MinuteUpButton", typeof(RepeatButton))] - [TemplatePart("PART_SecondDownButton", typeof(RepeatButton))] - [TemplatePart("PART_SecondHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_SecondSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_SecondUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PeriodDownButton", typeof(RepeatButton))] - [TemplatePart("PART_PeriodHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_PeriodSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_PeriodUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PickerContainer", typeof(Grid), IsRequired = true)] - [TemplatePart("PART_SecondSpacer", typeof(Rectangle), IsRequired = true)] - [TemplatePart("PART_ThirdSpacer", typeof(Rectangle), IsRequired = true)] + [TemplatePart(TemplateItems.AcceptButtonName, typeof(Button), IsRequired = true)] + [TemplatePart(TemplateItems.DismissButtonName, typeof(Button))] + [TemplatePart(TemplateItems.HourDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.HourSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.HourUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MinuteDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MinuteSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.MinuteUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.SecondDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.SecondHostName, typeof(Panel))] + [TemplatePart(TemplateItems.SecondSelectorName, typeof(DateTimePickerPanel))] + [TemplatePart(TemplateItems.SecondUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PeriodDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PeriodHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.PeriodSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.PeriodUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PickerContainerName, typeof(Grid), IsRequired = true)] + [TemplatePart(TemplateItems.SecondSpacerName, typeof(Control), IsRequired = true)] + [TemplatePart(TemplateItems.ThirdSpacerName, typeof(Control))] public class TimePickerPresenter : PickerPresenterBase { /// @@ -37,7 +36,7 @@ namespace Avalonia.Controls /// public static readonly StyledProperty MinuteIncrementProperty = TimePicker.MinuteIncrementProperty.AddOwner(); - + /// /// Defines the property /// @@ -49,7 +48,7 @@ namespace Avalonia.Controls /// public static readonly StyledProperty ClockIdentifierProperty = TimePicker.ClockIdentifierProperty.AddOwner(); - + /// /// Defines the property /// @@ -72,26 +71,54 @@ namespace Avalonia.Controls KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); } - // TemplateItems - private Grid? _pickerContainer; - private Button? _acceptButton; - private Button? _dismissButton; - private Rectangle? _spacer2; - private Rectangle? _spacer3; - private Panel? _secondHost; - private Panel? _periodHost; - private DateTimePickerPanel? _hourSelector; - private DateTimePickerPanel? _minuteSelector; - private DateTimePickerPanel? _secondSelector; - private DateTimePickerPanel? _periodSelector; - private Button? _hourUpButton; - private Button? _minuteUpButton; - private Button? _secondUpButton; - private Button? _periodUpButton; - private Button? _hourDownButton; - private Button? _minuteDownButton; - private Button? _secondDownButton; - private Button? _periodDownButton; + private struct TemplateItems + { + public Grid _pickerContainer; + public const string PickerContainerName = "PART_PickerContainer"; + + public Button _acceptButton; + public const string AcceptButtonName = "PART_AcceptButton"; + public Button? _dismissButton; + public const string DismissButtonName = "PART_DismissButton"; + + public Control _secondSpacer; // the 2nd spacer, not seconds of time + public const string SecondSpacerName = "PART_SecondSpacer"; + public Control? _thirdSpacer; + public const string ThirdSpacerName = "PART_ThirdSpacer"; + + public Panel? _secondHost; + public const string SecondHostName = "PART_SecondHost"; + public Panel _periodHost; + public const string PeriodHostName = "PART_PeriodHost"; + + public DateTimePickerPanel _hourSelector; + public const string HourSelectorName = "PART_HourSelector"; + public DateTimePickerPanel _minuteSelector; + public const string MinuteSelectorName = "PART_MinuteSelector"; + public DateTimePickerPanel? _secondSelector; + public const string SecondSelectorName = "PART_SecondSelector"; + public DateTimePickerPanel _periodSelector; + public const string PeriodSelectorName = "PART_PeriodSelector"; + + public Button? _hourUpButton; + public const string HourUpButtonName = "PART_HourUpButton"; + public Button? _minuteUpButton; + public const string MinuteUpButtonName = "PART_MinuteUpButton"; + public Button? _secondUpButton; + public const string SecondUpButtonName = "PART_SecondUpButton"; + public Button? _periodUpButton; + public const string PeriodUpButtonName = "PART_PeriodUpButton"; + public Button? _hourDownButton; + public const string HourDownButtonName = "PART_HourDownButton"; + public Button? _minuteDownButton; + public const string MinuteDownButtonName = "PART_MinuteDownButton"; + public Button? _secondDownButton; + public const string SecondDownButtonName = "PART_SecondDownButton"; + public Button? _periodDownButton; + public const string PeriodDownButtonName = "PART_PeriodDownButton"; + } + + private TemplateItems? _templateItems; /// /// Gets or sets the minute increment in the selector @@ -101,7 +128,7 @@ namespace Avalonia.Controls get => GetValue(MinuteIncrementProperty); set => SetValue(MinuteIncrementProperty, value); } - + /// /// Gets or sets the second increment in the selector /// @@ -119,7 +146,7 @@ namespace Avalonia.Controls get => GetValue(ClockIdentifierProperty); set => SetValue(ClockIdentifierProperty, value); } - + /// /// Gets or sets the current clock identifier, either 12HourClock or 24HourClock /// @@ -142,54 +169,54 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); - _pickerContainer = e.NameScope.Get("PART_PickerContainer"); - _periodHost = e.NameScope.Get("PART_PeriodHost"); - _secondHost = e.NameScope.Find("PART_SecondHost"); - - _hourSelector = e.NameScope.Get("PART_HourSelector"); - _minuteSelector = e.NameScope.Get("PART_MinuteSelector"); - _secondSelector = e.NameScope.Find("PART_SecondSelector"); - _periodSelector = e.NameScope.Get("PART_PeriodSelector"); - - _spacer2 = e.NameScope.Get("PART_SecondSpacer"); - _spacer3 = e.NameScope.Find("PART_ThirdSpacer"); - - _acceptButton = e.NameScope.Get private bool SetupDefinitionsToResize() { - int gridSpan = GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ? + // Get properties values from ContentPresenter if Grid it's used in ItemsControl as ItemsPanel otherwise directly from GridSplitter. + var sourceControl = GetPropertiesValueSource(); + int gridSpan = sourceControl.GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnSpanProperty : Grid.RowSpanProperty); if (gridSpan == 1) { - var splitterIndex = GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ? + var splitterIndex = sourceControl.GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnProperty : Grid.RowProperty); @@ -351,6 +356,81 @@ namespace Avalonia.Controls } } + /// + /// Retrieves the that ultimately hosts this + /// in the visual/logical tree. + /// + /// + /// A splitter can be placed directly inside a or + /// indirectly inside an that uses a + /// as its . + /// In the latter case the first logical parent is usually an + /// (or the items control itself), + /// so the method walks these intermediate containers to locate the + /// underlying grid. + /// + /// + /// The containing if one is found; otherwise + /// null. + /// + protected virtual Grid? GetParentGrid() + { + // When GridSplitter is used inside an ItemsControl with Grid as + // its ItemsPanel, its immediate parent is usually a ItemsControl or ContentPresenter. + switch (Parent) + { + case Grid grid: + { + return grid; + } + case ItemsControl itemsControl: + { + if (itemsControl.ItemsPanelRoot is Grid grid) + { + return grid; + } + + break; + } + case ContentPresenter { Parent: ItemsControl presenterItemsControl }: + { + if (presenterItemsControl.ItemsPanelRoot is Grid grid) + { + return grid; + } + + break; + } + } + + return null; + } + + /// + /// Returns the element that carries the grid-attached properties + /// (, , etc.) relevant + /// to this . + /// + /// + /// When the splitter is generated as part of an + /// template, the attached properties are set on the surrounding + /// rather than on the splitter itself. + /// This helper selects that presenter when appropriate so subsequent + /// property look-ups read the correct values; otherwise it simply + /// returns this. + /// + /// + /// The from which grid-attached properties + /// should be read—either the parent or + /// the splitter instance. + /// + protected virtual StyledElement GetPropertiesValueSource() + { + return Parent is ContentPresenter + ? Parent + : this; + } + protected override void OnPointerEntered(PointerEventArgs e) { base.OnPointerEntered(e); diff --git a/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs b/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs index cdb8fb21fe..c0974ea97b 100644 --- a/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia.UnitTests; using Moq; @@ -380,5 +383,164 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(columnDefinitions[0].Width, new GridLength(1, GridUnitType.Star)); Assert.Equal(columnDefinitions[2].Width, new GridLength(1, GridUnitType.Star)); } + + [Fact] + public void Works_In_ItemsControl_ItemsSource() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + +"; + + var itemsControl = AvaloniaRuntimeXamlLoader.Parse(xaml); + itemsControl.ItemsSource = new List + { + new TextItem { Column = 0, Text = "A" }, + new SplitterItem { Column = 1 }, + new TextItem { Column = 2, Text = "B" }, + }; + + var root = new TestRoot { Child = itemsControl }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var panel = Assert.IsType(itemsControl.ItemsPanelRoot); + var cp = Assert.IsType(panel.Children[1]); + cp.UpdateChild(); + var splitter = Assert.IsType(cp.Child); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width); + } + + [Fact] + public void Works_In_ItemsControl_Items() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + + + + + + + + + + + + + + + + + + + +"; + + var itemsControl = AvaloniaRuntimeXamlLoader.Parse(xaml); + var root = new TestRoot { Child = itemsControl }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var panel = Assert.IsType(itemsControl.ItemsPanelRoot); + var splitter = Assert.IsType(panel.Children[1]); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width); + } + + [Fact] + public void Works_In_Grid() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + +"; + + var grid = AvaloniaRuntimeXamlLoader.Parse(xaml); + var root = new TestRoot { Child = grid }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var splitter = Assert.IsType(grid.Children[1]); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(grid.ColumnDefinitions[0].Width, grid.ColumnDefinitions[2].Width); + } + } + + public interface IGridItem + { + int Column { get; set; } + } + + public class TextItem : IGridItem + { + public int Column { get; set; } + public string? Text { get; set; } + } + + public class SplitterItem : IGridItem + { + public int Column { get; set; } } } From 06d003b4a84210475fa76b79cc3f2d475d7f3a13 Mon Sep 17 00:00:00 2001 From: yoyo <143886016+zrt2399@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:49:13 +0800 Subject: [PATCH 007/154] Enable Copy and Cut in ContextMenu for AutoCompleteBox (#19087) Co-authored-by: Julien Lebosquain --- .../AutoCompleteBox.Properties.cs | 23 +++++++++++++++---- .../AutoCompleteBox/AutoCompleteBox.cs | 5 +++- .../Controls/AutoCompleteBox.xaml | 1 + .../Controls/AutoCompleteBox.xaml | 1 + 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs index 70be01e316..a667087c37 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs @@ -22,7 +22,7 @@ namespace Avalonia.Controls public static readonly StyledProperty CaretIndexProperty = TextBox.CaretIndexProperty.AddOwner(new( defaultValue: 0, - defaultBindingMode:BindingMode.TwoWay)); + defaultBindingMode: BindingMode.TwoWay)); public static readonly StyledProperty WatermarkProperty = TextBox.WatermarkProperty.AddOwner(); @@ -72,6 +72,12 @@ namespace Avalonia.Controls AvaloniaProperty.Register( nameof(ItemTemplate)); + /// + /// Defines the property + /// + public static readonly StyledProperty ClearSelectionOnLostFocusProperty = + TextBox.ClearSelectionOnLostFocusProperty.AddOwner(); + /// /// Identifies the property. /// @@ -295,6 +301,15 @@ namespace Avalonia.Controls set => SetValue(IsDropDownOpenProperty, value); } + /// + /// Gets or sets a value that determines whether the clears its selection after it loses focus. + /// + public bool ClearSelectionOnLostFocus + { + get => GetValue(ClearSelectionOnLostFocusProperty); + set => SetValue(ClearSelectionOnLostFocusProperty, value); + } + /// /// Gets or sets the that /// is used to get the values for display in the text portion of @@ -484,7 +499,7 @@ namespace Avalonia.Controls get => GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } - + /// /// Gets or sets the maximum number of characters that the can accept. /// This constraint only applies for manually entered (user-inputted) text. @@ -494,7 +509,7 @@ namespace Avalonia.Controls get => GetValue(MaxLengthProperty); set => SetValue(MaxLengthProperty, value); } - + /// /// Gets or sets custom content that is positioned on the left side of the text layout box /// @@ -511,6 +526,6 @@ namespace Avalonia.Controls { get => GetValue(InnerRightContentProperty); set => SetValue(InnerRightContentProperty, value); - } + } } } diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs index 4241f6315f..f61f6ff2a1 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -813,7 +813,10 @@ namespace Avalonia.Controls _userCalledPopulate = false; - if (ContextMenu is not { IsOpen: true }) + var textBoxContextMenuIsOpen = TextBox?.ContextFlyout?.IsOpen == true || TextBox?.ContextMenu?.IsOpen == true; + var contextMenuIsOpen = ContextFlyout?.IsOpen == true || ContextMenu?.IsOpen == true; + + if (!textBoxContextMenuIsOpen && !contextMenuIsOpen && ClearSelectionOnLostFocus) { ClearTextBoxSelection(); } diff --git a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml index 3d9db1de3b..3196d262a1 100644 --- a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml @@ -42,6 +42,7 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" CaretIndex="{TemplateBinding CaretIndex, Mode=TwoWay}" + ClearSelectionOnLostFocus="{TemplateBinding ClearSelectionOnLostFocus}" Padding="{TemplateBinding Padding}" Margin="0" DataValidationErrors.Errors="{TemplateBinding (DataValidationErrors.Errors)}" diff --git a/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml b/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml index 23b756cdc9..10b48a12f6 100644 --- a/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml @@ -17,6 +17,7 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" CaretIndex="{TemplateBinding CaretIndex, Mode=TwoWay}" + ClearSelectionOnLostFocus="{TemplateBinding ClearSelectionOnLostFocus}" DataValidationErrors.Errors="{TemplateBinding (DataValidationErrors.Errors)}" Watermark="{TemplateBinding Watermark}" MaxLength="{TemplateBinding MaxLength}" From df578394f0ab800165a3bceee218f709adc25b01 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 12 Aug 2025 12:21:26 +0200 Subject: [PATCH 008/154] Improved NuGet package creation docs. (#19196) * Added NuGet package creation docs. Moved the information about creating local NuGet packages from the general build instructions into its own document, and tried to flesh out the information in there. * Restore the link. As suggested in PR review. --------- Co-authored-by: Julien Lebosquain --- docs/build.md | 23 ++++----------------- docs/index.md | 1 + docs/nuget.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 docs/nuget.md diff --git a/docs/build.md b/docs/build.md index 643c767736..d54bdeee20 100644 --- a/docs/build.md +++ b/docs/build.md @@ -97,25 +97,6 @@ On macOS it is necessary to build and manually install the respective native lib ./build.sh CompileNative ``` -# Building Avalonia into a local NuGet cache - -It is possible to build Avalonia locally and generate NuGet packages that can be used locally to test local changes. - -First, install Nuke's dotnet global tool like so: - -```bash -dotnet tool install Nuke.GlobalTool --global -``` - -Then you need to run: -```bash -nuke --target BuildToNuGetCache --configuration Release -``` - -This command will generate nuget packages and push them into a local NuGet automatically. -To use these packages use `9999.0.0-localbuild` package version. -Each time local changes are made to Avalonia, running this command again will replace old packages and reset cache for the same version. - ## Browser To build and run browser/wasm projects, it's necessary to install NodeJS. @@ -124,3 +105,7 @@ You can find latest LTS on https://nodejs.org/. ## Windows It is possible to run some .NET Framework samples and tests using .NET Framework SDK. You need to install at least 4.7 SDK. + +## Building Avalonia into a local NuGet cache + +See [Building Local NuGet Packages](nuget.md) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 6adc4fd450..c8355c0f74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ This documentation covers Avalonia framework development. For user documentation - [Debugging the XAML Compiler](debug-xaml-compiler.md) - [Porting Code from 3rd Party Sources](porting-code-from-3rd-party-sources.md) +- [Building Local NuGet Packages](nuget.md) ## Releases diff --git a/docs/nuget.md b/docs/nuget.md new file mode 100644 index 0000000000..3ccb571bab --- /dev/null +++ b/docs/nuget.md @@ -0,0 +1,56 @@ +# Building Local NuGet Packages + +To build NuGet packages, one can use the `CreateNugetPackages` target: + +Windows + +``` +.\build.ps1 CreateNugetPackages +``` + +Linux/macOS + +``` +./build.sh CreateNugetPackages +``` + +Or if you have Nuke's [dotnet global tool](https://nuke.build/docs/getting-started/installation/) installed: + +``` +nuke CreateNugetPackages +``` + +The produced NuGet packages will be placed in the `artifacts\nuget` directory. + +> [!NOTE] +> The rest of this document will assume that you have the Nuke global tool installed, as the invocation is the same on all platforms. You can always replace `nuke` in the instructions below with the `build` script relvant to your platform. + +By default the packages will be built in debug configuration. To build in relase configuration add the `--configuration` parameter, e.g.: + +``` +nuke CreateNugetPackages --configuration Release +``` + +To configure the version of the built packages, add the `--force-nuget-version` parameter, e.g.: + +``` +nuke CreateNugetPackages --force-nuget-version 11.4.0 +``` + +## Building to the Local Cache + +Building packages with the `CreateNugetPackages` target has a few gotchas: + +- One needs to set up a local nuget feed to consume the packages +- When building on an operating system other than macOS, the Avalonia.Native package will not be built, resulting in a NuGet error when trying to use Avalonia.Desktop +- It's easy to introduce versioning problems + +For these reasons, it is possible to build Avalonia directly to your machine's NuGet cache using the `BuildToNuGetCache` target: + +```bash +nuke --target BuildToNuGetCache --configuration Release +``` + +This command will generate nuget packages and push them into your local NuGet cache (usually `~/.nuget/packages`) with a version of `9999.0.0-localbuild`. + +Each time local changes are made to Avalonia, running this command again will replace the old packages and reset the cache, meaning that the changes should be picked up automatically by msbuild. From 1f8701a2fc1c85f41752dea4ebc601af213d356c Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 13 Aug 2025 10:35:52 +0200 Subject: [PATCH 009/154] Add Window.CanMinimize/CanMaximize (#18117) * Add CanMinimize and CanMaximize to Window * Win32 impl for CanMinimize/CanMaximize * Add CanResize/CanMinimize/CanMaximize samples to control catalog * X11 impl for CanMinimize/CanMaximize * macOS impl for CanMinimize/CanMaximize * Win32: don't allow restore when the window isn't resizable * Additional documentation for CanMinimize/CanMaximize * Add CanMinimize/CanMaximize logic to CaptionButtons * Use START_COM_ARP_CALL * Added CanMinimize/CanMaximize integration tests * Fixed CanMaximize tests on macOS --- native/Avalonia.Native/src/OSX/WindowImpl.h | 8 ++- native/Avalonia.Native/src/OSX/WindowImpl.mm | 27 ++++++-- samples/ControlCatalog/MainWindow.xaml | 3 + .../Pages/WindowCustomizationsPage.xaml | 49 ++++++++++++-- .../ViewModels/MainWindowViewModel.cs | 64 +++++++++++++++---- .../IntegrationTestApp/Pages/WindowPage.axaml | 6 +- .../Pages/WindowPage.axaml.cs | 5 +- .../Chrome/CaptionButtons.cs | 55 ++++++++++++++-- src/Avalonia.Controls/Platform/IWindowImpl.cs | 11 +++- src/Avalonia.Controls/Window.cs | 53 ++++++++++++++- .../Remote/PreviewerWindowImpl.cs | 8 +++ src/Avalonia.DesignerSupport/Remote/Stubs.cs | 8 +++ src/Avalonia.Native/WindowImpl.cs | 25 +++++++- src/Avalonia.Native/avn.idl | 2 + .../Controls/CaptionButtons.xaml | 2 +- src/Avalonia.X11/X11Window.cs | 38 ++++++++--- .../Avalonia.Headless/HeadlessWindowImpl.cs | 8 +++ .../Avalonia.Win32/EmbeddedWindowImpl.cs | 2 + src/Windows/Avalonia.Win32/PopupImpl.cs | 2 + src/Windows/Avalonia.Win32/WindowImpl.cs | 36 +++++++++-- .../WindowTests.cs | 17 +++++ .../WindowTests.cs | 43 ++++++++++++- 22 files changed, 418 insertions(+), 54 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.h b/native/Avalonia.Native/src/OSX/WindowImpl.h index 37699082ed..7b911ef945 100644 --- a/native/Avalonia.Native/src/OSX/WindowImpl.h +++ b/native/Avalonia.Native/src/OSX/WindowImpl.h @@ -45,6 +45,10 @@ BEGIN_INTERFACE_MAP() void DoZoom(); virtual HRESULT SetCanResize(bool value) override; + + virtual HRESULT SetCanMinimize(bool value) override; + + virtual HRESULT SetCanMaximize(bool value) override; virtual HRESULT SetDecorations(SystemDecorations value) override; @@ -82,7 +86,7 @@ BEGIN_INTERFACE_MAP() bool CanBecomeKeyWindow (); - bool CanZoom() override { return _isEnabled && _canResize; } + bool CanZoom() override { return _isEnabled && _canMaximize; } protected: virtual NSWindowStyleMask CalculateStyleMask() override; @@ -94,6 +98,8 @@ private: NSString *_lastTitle; bool _isEnabled; bool _canResize; + bool _canMinimize; + bool _canMaximize; bool _fullScreenActive; SystemDecorations _decorations; AvnWindowState _lastWindowState; diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.mm b/native/Avalonia.Native/src/OSX/WindowImpl.mm index 341085ec08..5a57715b55 100644 --- a/native/Avalonia.Native/src/OSX/WindowImpl.mm +++ b/native/Avalonia.Native/src/OSX/WindowImpl.mm @@ -16,6 +16,8 @@ WindowImpl::WindowImpl(IAvnWindowEvents *events) : TopLevelImpl(events), WindowB _extendClientHints = AvnDefaultChrome; _fullScreenActive = false; _canResize = true; + _canMinimize = true; + _canMaximize = true; _decorations = SystemDecorationsFull; _transitioningWindowState = false; _inSetWindowState = false; @@ -191,7 +193,8 @@ bool WindowImpl::IsZoomed() { void WindowImpl::DoZoom() { if (_decorations == SystemDecorationsNone || _decorations == SystemDecorationsBorderOnly || - _canResize == false) { + _canResize == false || + _canMaximize == false) { [Window setFrame:[Window screen].visibleFrame display:true]; } else { [Window performZoom:Window]; @@ -208,6 +211,22 @@ HRESULT WindowImpl::SetCanResize(bool value) { } } +HRESULT WindowImpl::SetCanMinimize(bool value) { + START_COM_ARP_CALL; + + _canMinimize = value; + UpdateAppearance(); + return S_OK; +} + +HRESULT WindowImpl::SetCanMaximize(bool value) { + START_COM_ARP_CALL; + + _canMaximize = value; + UpdateAppearance(); + return S_OK; +} + HRESULT WindowImpl::SetDecorations(SystemDecorations value) { START_COM_CALL; @@ -583,7 +602,7 @@ NSWindowStyleMask WindowImpl::CalculateStyleMask() { break; } - if (!IsOwned()) { + if (_canMinimize && !IsOwned()) { s |= NSWindowStyleMaskMiniaturizable; } @@ -611,9 +630,9 @@ void WindowImpl::UpdateAppearance() { [closeButton setHidden:!hasTrafficLights]; [closeButton setEnabled:_isEnabled]; [miniaturizeButton setHidden:!hasTrafficLights]; - [miniaturizeButton setEnabled:_isEnabled]; + [miniaturizeButton setEnabled:_isEnabled && _canMinimize]; [zoomButton setHidden:!hasTrafficLights]; - [zoomButton setEnabled:CanZoom()]; + [zoomButton setEnabled:CanZoom() || (([Window styleMask] & NSWindowStyleMaskFullScreen) != 0 && _isEnabled)]; } extern IAvnWindow* CreateAvnWindow(IAvnWindowEvents*events) diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index adcfc285cd..53f41672a9 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -10,6 +10,9 @@ ExtendClientAreaToDecorationsHint="{Binding ExtendClientAreaEnabled}" ExtendClientAreaChromeHints="{Binding ChromeHints}" ExtendClientAreaTitleBarHeightHint="{Binding TitleBarHeight}" + CanResize="{Binding CanResize}" + CanMinimize="{Binding CanMinimize}" + CanMaximize="{Binding CanMaximize}" x:Name="MainWindow" Background="Transparent" x:Class="ControlCatalog.MainWindow" WindowState="{Binding WindowState, Mode=TwoWay}" diff --git a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml index bcc1a71243..a647d34356 100644 --- a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml +++ b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml @@ -8,18 +8,55 @@ x:DataType="viewModels:MainWindowViewModel" x:CompileBindings="True"> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs index 1050beedf2..716675f2c6 100644 --- a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs +++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Controls.Notifications; using Avalonia.Dialogs; using Avalonia.Platform; using System; @@ -22,6 +21,9 @@ namespace ControlCatalog.ViewModels private bool _isSystemBarVisible; private bool _displayEdgeToEdge; private Thickness _safeAreaPadding; + private bool _canResize; + private bool _canMinimize; + private bool _canMaximize; public MainWindowViewModel() { @@ -49,7 +51,7 @@ namespace ControlCatalog.ViewModels WindowState.FullScreen, }; - this.PropertyChanged += (s, e) => + PropertyChanged += (s, e) => { if (e.PropertyName is nameof(SystemTitleBarEnabled) or nameof(PreferSystemChromeEnabled)) { @@ -67,70 +69,104 @@ namespace ControlCatalog.ViewModels } }; - SystemTitleBarEnabled = true; + SystemTitleBarEnabled = true; TitleBarHeight = -1; + CanResize = true; + CanMinimize = true; + CanMaximize = true; } public ExtendClientAreaChromeHints ChromeHints { get { return _chromeHints; } - set { this.RaiseAndSetIfChanged(ref _chromeHints, value); } + set { RaiseAndSetIfChanged(ref _chromeHints, value); } } public bool ExtendClientAreaEnabled { get { return _extendClientAreaEnabled; } - set { this.RaiseAndSetIfChanged(ref _extendClientAreaEnabled, value); } + set + { + if (RaiseAndSetIfChanged(ref _extendClientAreaEnabled, value) && !value) + { + SystemTitleBarEnabled = true; + } + } } public bool SystemTitleBarEnabled { get { return _systemTitleBarEnabled; } - set { this.RaiseAndSetIfChanged(ref _systemTitleBarEnabled, value); } + set + { + if (RaiseAndSetIfChanged(ref _systemTitleBarEnabled, value) && !value) + { + TitleBarHeight = -1; + } + } } public bool PreferSystemChromeEnabled { get { return _preferSystemChromeEnabled; } - set { this.RaiseAndSetIfChanged(ref _preferSystemChromeEnabled, value); } + set { RaiseAndSetIfChanged(ref _preferSystemChromeEnabled, value); } } public double TitleBarHeight { get { return _titleBarHeight; } - set { this.RaiseAndSetIfChanged(ref _titleBarHeight, value); } + set { RaiseAndSetIfChanged(ref _titleBarHeight, value); } } public WindowState WindowState { get { return _windowState; } - set { this.RaiseAndSetIfChanged(ref _windowState, value); } + set { RaiseAndSetIfChanged(ref _windowState, value); } } public WindowState[] WindowStates { get { return _windowStates; } - set { this.RaiseAndSetIfChanged(ref _windowStates, value); } + set { RaiseAndSetIfChanged(ref _windowStates, value); } } public bool IsSystemBarVisible { get { return _isSystemBarVisible; } - set { this.RaiseAndSetIfChanged(ref _isSystemBarVisible, value); } + set { RaiseAndSetIfChanged(ref _isSystemBarVisible, value); } } public bool DisplayEdgeToEdge { get { return _displayEdgeToEdge; } - set { this.RaiseAndSetIfChanged(ref _displayEdgeToEdge, value); } + set { RaiseAndSetIfChanged(ref _displayEdgeToEdge, value); } } public Thickness SafeAreaPadding { get { return _safeAreaPadding; } - set { this.RaiseAndSetIfChanged(ref _safeAreaPadding, value); } + set { RaiseAndSetIfChanged(ref _safeAreaPadding, value); } + } + + public bool CanResize + { + get { return _canResize; } + set { RaiseAndSetIfChanged(ref _canResize, value); } + } + + public bool CanMinimize + { + get { return _canMinimize; } + set { RaiseAndSetIfChanged(ref _canMinimize, value); } } + public bool CanMaximize + { + get { return _canMaximize; } + set { RaiseAndSetIfChanged(ref _canMaximize, value); } + } + + public MiniCommand AboutCommand { get; } public MiniCommand ExitCommand { get; } @@ -144,7 +180,7 @@ namespace ControlCatalog.ViewModels public DateTime? ValidatedDateExample { get => _validatedDateExample; - set => this.RaiseAndSetIfChanged(ref _validatedDateExample, value); + set => RaiseAndSetIfChanged(ref _validatedDateExample, value); } } } diff --git a/samples/IntegrationTestApp/Pages/WindowPage.axaml b/samples/IntegrationTestApp/Pages/WindowPage.axaml index db2298b06e..60900a4778 100644 --- a/samples/IntegrationTestApp/Pages/WindowPage.axaml +++ b/samples/IntegrationTestApp/Pages/WindowPage.axaml @@ -30,14 +30,16 @@ ExtendClientAreaToDecorationsHint Can Resize + Can Minimize + Can Maximize + + - - diff --git a/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs b/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs index 5549e537d3..b7f505a7b2 100644 --- a/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs +++ b/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs @@ -23,10 +23,13 @@ public partial class WindowPage : UserControl private void ShowWindow_Click(object? sender, RoutedEventArgs e) { var size = !string.IsNullOrWhiteSpace(ShowWindowSize.Text) ? Size.Parse(ShowWindowSize.Text) : (Size?)null; + var canResize = ShowWindowCanResize.IsChecked ?? false; var window = new ShowWindowTest { WindowStartupLocation = (WindowStartupLocation)ShowWindowLocation.SelectedIndex, - CanResize = ShowWindowCanResize.IsChecked ?? false, + CanResize = canResize, + CanMinimize = ShowWindowCanMinimize.IsChecked ?? false, + CanMaximize = canResize && (ShowWindowCanMaximize.IsChecked ?? false) }; if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) diff --git a/src/Avalonia.Controls/Chrome/CaptionButtons.cs b/src/Avalonia.Controls/Chrome/CaptionButtons.cs index 07ecbb188e..32bdd8fa96 100644 --- a/src/Avalonia.Controls/Chrome/CaptionButtons.cs +++ b/src/Avalonia.Controls/Chrome/CaptionButtons.cs @@ -21,6 +21,8 @@ namespace Avalonia.Controls.Chrome internal const string PART_FullScreenButton = "PART_FullScreenButton"; private Button? _restoreButton; + private Button? _minimizeButton; + private Button? _fullScreenButton; private IDisposable? _disposables; /// @@ -36,11 +38,16 @@ namespace Avalonia.Controls.Chrome _disposables = new CompositeDisposable { - HostWindow.GetObservable(Window.CanResizeProperty) - .Subscribe(x => + HostWindow.GetObservable(Window.CanMaximizeProperty) + .Subscribe(_ => + { + UpdateRestoreButtonState(); + UpdateFullScreenButtonState(); + }), + HostWindow.GetObservable(Window.CanMinimizeProperty) + .Subscribe(_ => { - if (_restoreButton is not null) - _restoreButton.IsEnabled = x; + UpdateMinimizeButtonState(); }), HostWindow.GetObservable(Window.WindowStateProperty) .Subscribe(x => @@ -49,6 +56,9 @@ namespace Avalonia.Controls.Chrome PseudoClasses.Set(":normal", x == WindowState.Normal); PseudoClasses.Set(":maximized", x == WindowState.Maximized); PseudoClasses.Set(":fullscreen", x == WindowState.FullScreen); + UpdateRestoreButtonState(); + UpdateMinimizeButtonState(); + UpdateFullScreenButtonState(); }), }; } @@ -116,8 +126,8 @@ namespace Avalonia.Controls.Chrome OnRestore(); args.Handled = true; }; - restoreButton.IsEnabled = HostWindow?.CanResize ?? true; _restoreButton = restoreButton; + UpdateRestoreButtonState(); } if (e.NameScope.Find void CanResize(bool value); + /// + /// Enables or disables minimizing the window. + /// + void SetCanMinimize(bool value); + + /// + /// Enables or disables maximizing the window. + /// + void SetCanMaximize(bool value); + /// /// Gets or sets a method called before the underlying implementation is destroyed. /// Return true to prevent the underlying implementation from closing. @@ -124,7 +134,6 @@ namespace Avalonia.Platform /// /// Minimum width of the window. /// - /// void SetMinMaxSize(Size minSize, Size maxSize); /// diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 6fb203b035..1f5d12c536 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -181,9 +181,24 @@ namespace Avalonia.Controls public static readonly StyledProperty WindowStartupLocationProperty = AvaloniaProperty.Register(nameof(WindowStartupLocation)); + /// + /// Defines the property. + /// public static readonly StyledProperty CanResizeProperty = AvaloniaProperty.Register(nameof(CanResize), true); + /// + /// Defines the property. + /// + public static readonly StyledProperty CanMinimizeProperty = + AvaloniaProperty.Register(nameof(CanMinimize), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty CanMaximizeProperty = + AvaloniaProperty.Register(nameof(CanMaximize), true, coerce: CoerceCanMaximize); + /// /// Routed event that can be used for global tracking of window destruction /// @@ -236,6 +251,8 @@ namespace Avalonia.Controls CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title)); CreatePlatformImplBinding(IconProperty, icon => PlatformImpl!.SetIcon((icon ?? s_defaultIcon.Value)?.PlatformImpl)); CreatePlatformImplBinding(CanResizeProperty, canResize => PlatformImpl!.CanResize(canResize)); + CreatePlatformImplBinding(CanMinimizeProperty, canMinimize => PlatformImpl!.SetCanMinimize(canMinimize)); + CreatePlatformImplBinding(CanMaximizeProperty, canMaximize => PlatformImpl!.SetCanMaximize(canMaximize)); CreatePlatformImplBinding(ShowInTaskbarProperty, show => PlatformImpl!.ShowTaskbarIcon(show)); CreatePlatformImplBinding(WindowStateProperty, state => PlatformImpl!.WindowState = state); @@ -407,6 +424,32 @@ namespace Avalonia.Controls set => SetValue(CanResizeProperty, value); } + /// + /// Enables or disables minimizing the window. + /// + /// + /// This property might be ignored by some window managers on Linux. + /// + public bool CanMinimize + { + get => GetValue(CanMinimizeProperty); + set => SetValue(CanMinimizeProperty, value); + } + + /// + /// Enables or disables maximizing the window. + /// + /// + /// When is false, this property is always false. + /// On macOS, setting this property to false also disables the full screen mode. + /// This property might be ignored by some window managers on Linux. + /// + public bool CanMaximize + { + get => GetValue(CanMaximizeProperty); + set => SetValue(CanMaximizeProperty, value); + } + /// /// Gets or sets the icon of the window. /// @@ -1184,7 +1227,7 @@ namespace Avalonia.Controls PlatformImpl?.SetSystemDecorations(typedNewValue); } - if (change.Property == OwnerProperty) + else if (change.Property == OwnerProperty) { var oldParent = change.OldValue as Window; var newParent = change.NewValue as Window; @@ -1197,6 +1240,11 @@ namespace Avalonia.Controls impl.SetParent(_showingAsDialog ? newParent?.PlatformImpl! : (newParent?.PlatformImpl ?? null)); } } + + else if (change.Property == CanResizeProperty) + { + CoerceValue(CanMaximizeProperty); + } } protected override AutomationPeer OnCreateAutomationPeer() @@ -1217,5 +1265,8 @@ namespace Avalonia.Controls } return null; } + + private static bool CoerceCanMaximize(AvaloniaObject target, bool value) + => value && target is not Window { CanResize: false }; } } diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index 84c1d7db19..61924f53ab 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -133,6 +133,14 @@ namespace Avalonia.DesignerSupport.Remote { } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public void SetTopmost(bool value) { } diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index a0e5f3c87e..e83c29c9af 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -152,6 +152,14 @@ namespace Avalonia.DesignerSupport.Remote { } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public void SetTopmost(bool value) { } diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index a278bda67e..08aaae669f 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -18,6 +18,7 @@ namespace Avalonia.Native private DoubleClickHelper _doubleClickHelper; private readonly ITopLevelNativeMenuExporter _nativeMenuExporter; private bool _canResize = true; + private bool _canMaximize = true; internal WindowImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts) : base(factory) { @@ -77,6 +78,17 @@ namespace Avalonia.Native _native.SetCanResize(value.AsComBool()); } + public void SetCanMinimize(bool value) + { + _native.SetCanMinimize(value.AsComBool()); + } + + public void SetCanMaximize(bool value) + { + _canMaximize = value; + _native.SetCanMaximize(value.AsComBool()); + } + public void SetSystemDecorations(Controls.SystemDecorations enabled) { _native.SetDecorations((Interop.SystemDecorations)enabled); @@ -138,10 +150,17 @@ namespace Avalonia.Native { if (_doubleClickHelper.IsDoubleClick(e.Timestamp, e.Position)) { - if (_canResize) + switch (WindowState) { - WindowState = WindowState is WindowState.Maximized or WindowState.FullScreen ? - WindowState.Normal : WindowState.Maximized; + case WindowState.Maximized or WindowState.FullScreen + when _canResize: + WindowState = WindowState.Normal; + break; + + case WindowState.Normal + when _canMaximize: + WindowState = WindowState.Maximized; + break; } } else diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index bc2d6ddd1a..97afb8667e 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -772,6 +772,8 @@ interface IAvnWindow : IAvnWindowBase { HRESULT SetEnabled(bool enable); HRESULT SetCanResize(bool value); + HRESULT SetCanMinimize(bool value); + HRESULT SetCanMaximize(bool value); HRESULT SetDecorations(SystemDecorations value); HRESULT SetTitle(char* utf8Title); HRESULT SetTitleBarColor(AvnColor color); diff --git a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml index bcaeac8012..d9f9343925 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml @@ -112,7 +112,7 @@ - diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 0fecf6a47d..eee61a707f 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -316,22 +316,28 @@ namespace Avalonia.X11 || _systemDecorations == SystemDecorations.None) decorations = 0; - if (!_canResize || !IsEnabled) + var isDisabled = !IsEnabled; + + if (!_canResize || isDisabled) { - functions &= ~(MotifFunctions.Resize | MotifFunctions.Maximize); - decorations &= ~(MotifDecorations.Maximize | MotifDecorations.ResizeH); + functions &= ~MotifFunctions.Resize; + decorations &= ~MotifDecorations.ResizeH; } - if (!IsEnabled) - { - functions &= ~(MotifFunctions.Resize | MotifFunctions.Minimize); - UpdateSizeHints(null, true); + if (!_canMinimize || isDisabled) + { + functions &= ~MotifFunctions.Minimize; + decorations &= ~MotifDecorations.Minimize; } - else + + if (!_canMaximize || isDisabled) { - UpdateSizeHints(null); + functions &= ~MotifFunctions.Maximize; + decorations &= ~MotifDecorations.Maximize; } + UpdateSizeHints(null, isDisabled); + var hints = new MotifWmHints { flags = new IntPtr((int)(MotifFlags.Decorations | MotifFlags.Functions)), @@ -857,6 +863,8 @@ namespace Avalonia.X11 private SystemDecorations _systemDecorations = SystemDecorations.Full; private bool _canResize = true; + private bool _canMinimize = true; + private bool _canMaximize = true; private const int MaxWindowDimension = 100000; private (Size minSize, Size maxSize) _scaledMinMaxSize = @@ -1162,6 +1170,18 @@ namespace Avalonia.X11 UpdateSizeHints(null); } + public void SetCanMinimize(bool value) + { + _canMinimize = value; + UpdateMotifHints(); + } + + public void SetCanMaximize(bool value) + { + _canMaximize = value; + UpdateMotifHints(); + } + public void SetCursor(ICursorImpl? cursor) { if (cursor == null) diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 64ce9945d4..2cd3df7c54 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -165,6 +165,14 @@ namespace Avalonia.Headless } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public Func? Closing { get; set; } private class FramebufferProxy : ILockedFramebuffer diff --git a/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs b/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs index 509be65784..a039d568c6 100644 --- a/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs +++ b/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs @@ -12,6 +12,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = false, + IsMinimizable = false, + IsMaximizable = false, Decorations = SystemDecorations.None }; } diff --git a/src/Windows/Avalonia.Win32/PopupImpl.cs b/src/Windows/Avalonia.Win32/PopupImpl.cs index 95c8178adc..8a8450443f 100644 --- a/src/Windows/Avalonia.Win32/PopupImpl.cs +++ b/src/Windows/Avalonia.Win32/PopupImpl.cs @@ -111,6 +111,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = false, + IsMinimizable = false, + IsMaximizable = false, Decorations = SystemDecorations.None, }; diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 784ad7c2aa..54e464720b 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -136,6 +136,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = true, + IsMinimizable = true, + IsMaximizable = true, Decorations = SystemDecorations.Full }; @@ -858,6 +860,24 @@ namespace Avalonia.Win32 UpdateWindowProperties(newWindowProperties); } + public void SetCanMinimize(bool value) + { + var newWindowProperties = _windowProperties; + + newWindowProperties.IsMinimizable = value; + + UpdateWindowProperties(newWindowProperties); + } + + public void SetCanMaximize(bool value) + { + var newWindowProperties = _windowProperties; + + newWindowProperties.IsMaximizable = value; + + UpdateWindowProperties(newWindowProperties); + } + public void SetSystemDecorations(SystemDecorations value) { var newWindowProperties = _windowProperties; @@ -1425,15 +1445,19 @@ namespace Avalonia.Win32 style |= WindowStyles.WS_VISIBLE; if (newProperties.IsResizable || newProperties.WindowState == WindowState.Maximized) - { style |= WindowStyles.WS_THICKFRAME; - style |= WindowStyles.WS_MAXIMIZEBOX; - } else - { style &= ~WindowStyles.WS_THICKFRAME; + + if (newProperties.IsMinimizable) + style |= WindowStyles.WS_MINIMIZEBOX; + else + style &= ~WindowStyles.WS_MINIMIZEBOX; + + if (newProperties.IsMaximizable || (newProperties.WindowState == WindowState.Maximized && newProperties.IsResizable)) + style |= WindowStyles.WS_MAXIMIZEBOX; + else style &= ~WindowStyles.WS_MAXIMIZEBOX; - } const WindowStyles fullDecorationFlags = WindowStyles.WS_CAPTION | WindowStyles.WS_SYSMENU | WindowStyles.WS_BORDER; @@ -1656,6 +1680,8 @@ namespace Avalonia.Win32 { public bool ShowInTaskbar; public bool IsResizable; + public bool IsMinimizable; + public bool IsMaximizable; public SystemDecorations Decorations; public bool IsFullScreen; public WindowState WindowState; diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index a7f2692a57..e769f350af 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -672,6 +672,23 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void CanMaximize_Should_Be_False_If_CanResize_Is_False() + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + + using var app = UnitTestApplication.Start(TestServices.StyledWindow.With( + windowingPlatform: new MockWindowingPlatform(() => windowImpl.Object))); + + var window = new Window(); + + Assert.True(window.CanMaximize); + + window.CanResize = false; + + Assert.False(window.CanMaximize); + } + public class SizingTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs index d4a6380b5d..b6cbf38adf 100644 --- a/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs +++ b/tests/Avalonia.IntegrationTests.Appium/WindowTests.cs @@ -311,6 +311,37 @@ namespace Avalonia.IntegrationTests.Appium } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Window_Minimize_Button_Enabled_Matches_CanMinimize(bool canMinimize) + { + using (OpenWindow(canMinimize: canMinimize)) + { + var secondaryWindow = Session.GetWindowById("SecondaryWindow"); + var windowChrome = secondaryWindow.GetSystemChromeButtons(); + + Assert.NotNull(windowChrome.Minimize); + Assert.Equal(canMinimize, windowChrome.Minimize!.Enabled); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Window_Maximize_Button_Enabled_Matches_CanMaximize(bool canMaximize) + { + using (OpenWindow(canMaximize: canMaximize)) + { + var secondaryWindow = Session.GetWindowById("SecondaryWindow"); + var windowChrome = secondaryWindow.GetSystemChromeButtons(); + + var maximizeButton = windowChrome.FullScreen ?? windowChrome.Maximize; + Assert.NotNull(maximizeButton); + Assert.Equal(canMaximize, maximizeButton.Enabled); + } + } + [Fact] public void Changing_SystemDecorations_Should_Not_Change_Frame_Size_And_Position() { @@ -452,13 +483,17 @@ namespace Avalonia.IntegrationTests.Appium WindowStartupLocation location = WindowStartupLocation.Manual, WindowState state = Controls.WindowState.Normal, bool canResize = true, - bool extendClientArea = false) + bool extendClientArea = false, + bool canMinimize = true, + bool canMaximize = true) { var sizeTextBox = Session.FindElementByAccessibilityId("ShowWindowSize"); var modeComboBox = Session.FindElementByAccessibilityId("ShowWindowMode"); var locationComboBox = Session.FindElementByAccessibilityId("ShowWindowLocation"); var stateComboBox = Session.FindElementByAccessibilityId("ShowWindowState"); var canResizeCheckBox = Session.FindElementByAccessibilityId("ShowWindowCanResize"); + var canMinimizeCheckBox = Session.FindElementByAccessibilityId("ShowWindowCanMinimize"); + var canMaximizeCheckBox = Session.FindElementByAccessibilityId("ShowWindowCanMaximize"); var showButton = Session.FindElementByAccessibilityId("ShowWindow"); var extendClientAreaCheckBox = Session.FindElementByAccessibilityId("ShowWindowExtendClientAreaToDecorationsHint"); @@ -486,6 +521,12 @@ namespace Avalonia.IntegrationTests.Appium if (canResizeCheckBox.GetIsChecked() != canResize) canResizeCheckBox.Click(); + if (canMinimizeCheckBox.GetIsChecked() != canMinimize) + canMinimizeCheckBox.Click(); + + if (canMaximizeCheckBox.GetIsChecked() != canMaximize) + canMaximizeCheckBox.Click(); + if (extendClientAreaCheckBox.GetIsChecked() != extendClientArea) extendClientAreaCheckBox.Click(); From 5ab26da0fd2ceabdd3a3279e1f4a2a6713844045 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 13 Aug 2025 11:12:36 +0200 Subject: [PATCH 010/154] Fix SplitView incorrectly closing when the user changes DisplayMode to inline when IsPaneOpen is already true (#19460) * add a failing unit test for issue #19457 * fix #19457 by invalidating the pointerReleased subscription when DisplayMode changes. * [SplitView] ensure we always set _pointerDisposable to null when disposing. --- src/Avalonia.Controls/SplitView/SplitView.cs | 32 ++++++++++----- .../SplitViewTests.cs | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/Avalonia.Controls/SplitView/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs index fd6fd98de6..f78ac6e0ee 100644 --- a/src/Avalonia.Controls/SplitView/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -314,6 +314,7 @@ namespace Avalonia.Controls { base.OnDetachedFromVisualTree(e); _pointerDisposable?.Dispose(); + _pointerDisposable = null; } /// @@ -423,7 +424,7 @@ namespace Avalonia.Controls protected virtual void OnPaneOpened(RoutedEventArgs args) { - EnableLightDismiss(); + InvalidateLightDismissSubscription(); RaiseEvent(args); } @@ -528,6 +529,8 @@ namespace Avalonia.Controls }; TemplateSettings.ClosedPaneWidth = closedPaneWidth; TemplateSettings.PaneColumnGridLength = paneColumnGridLength; + + InvalidateLightDismissSubscription(); } private void UpdateVisualStateForPanePlacementProperty(SplitViewPanePlacement newValue) @@ -541,7 +544,7 @@ namespace Avalonia.Controls PseudoClasses.Add(_lastPlacementPseudoclass); } - private void EnableLightDismiss() + private void InvalidateLightDismissSubscription() { if (_pane == null) return; @@ -549,19 +552,26 @@ namespace Avalonia.Controls // If this returns false, we're not in Overlay or CompactOverlay DisplayMode // and don't need the light dismiss behavior if (!IsInOverlayMode()) + { + _pointerDisposable?.Dispose(); + _pointerDisposable = null; return; + } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel != null) + if (_pointerDisposable == null) { - _pointerDisposable = Disposable.Create(() => + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel != null) { - topLevel.PointerReleased -= PointerReleasedOutside; - topLevel.BackRequested -= TopLevelBackRequested; - }); - - topLevel.PointerReleased += PointerReleasedOutside; - topLevel.BackRequested += TopLevelBackRequested; + _pointerDisposable = Disposable.Create(() => + { + topLevel.PointerReleased -= PointerReleasedOutside; + topLevel.BackRequested -= TopLevelBackRequested; + }); + + topLevel.PointerReleased += PointerReleasedOutside; + topLevel.BackRequested += TopLevelBackRequested; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs index 26d572d416..5dc4661a5f 100644 --- a/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs @@ -303,5 +303,45 @@ namespace Avalonia.Controls.UnitTests Assert.Contains(splitView.Classes, ":closed".Equals); } + + [Fact] + public void SplitView_Shouldnt_Close_Panel_When_IsPaneOpen_True_Then_Display_Mode_Changed() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow + .With(globalClock: new MockGlobalClock())); + var wnd = new Window + { + Width = 1280, + Height = 720 + }; + var splitView = new SplitView(); + splitView.DisplayMode = SplitViewDisplayMode.CompactOverlay; + wnd.Content = splitView; + wnd.Show(); + + splitView.IsPaneOpen = true; + + splitView.RaiseEvent(new PointerReleasedEventArgs(splitView, + null, wnd, new Point(1270, 30), 0, + new PointerPointProperties(), + KeyModifiers.None, + MouseButton.Left)); + + Assert.False(splitView.IsPaneOpen); + + // Inline shouldn't close the pane + splitView.IsPaneOpen = true; + + // Change the display mode once the pane is already open. + splitView.DisplayMode = SplitViewDisplayMode.Inline; + + splitView.RaiseEvent(new PointerReleasedEventArgs(splitView, + null, wnd, new Point(1270, 30), 0, + new PointerPointProperties(), + KeyModifiers.None, + MouseButton.Left)); + + Assert.True(splitView.IsPaneOpen); + } } } From eff1c36ea8aa27ae114714c62fdd95b9f12726db Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 13 Aug 2025 20:55:29 +0900 Subject: [PATCH 011/154] Add AllowTapRangeSelection for Calendar (#19367) * Add AllowTapRangeSelection for Calendar * Fix spacing * Let it be true... * Update CalendarTests.cs --- .../ControlCatalog/Pages/CalendarPage.xaml | 5 + src/Avalonia.Controls/Calendar/Calendar.cs | 135 ++++++++++++++++++ .../Calendar/CalendarItem.cs | 9 ++ .../CalendarTests.cs | 92 ++++++++++++ 4 files changed, 241 insertions(+) diff --git a/samples/ControlCatalog/Pages/CalendarPage.xaml b/samples/ControlCatalog/Pages/CalendarPage.xaml index cfae7140b0..e142c4da72 100644 --- a/samples/ControlCatalog/Pages/CalendarPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarPage.xaml @@ -32,6 +32,11 @@ + + + + AllowTapRangeSelectionProperty = + AvaloniaProperty.Register( + nameof(AllowTapRangeSelection), + defaultValue: true); + /// /// Gets or sets a value that indicates what kind of selections are /// allowed. @@ -462,6 +469,24 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// Gets or sets a value indicating whether tap-to-select range mode is enabled. + /// When enabled, users can tap a start date and then tap an end date to select a range. + /// + /// + /// True to enable tap range selection; otherwise, false. The default is false. + /// + /// + /// This feature only works when SelectionMode is set to SingleRange. + /// When enabled, the first tap selects the start date, and the second tap selects + /// the end date to complete the range. Tapping a third date starts a new range. + /// + public bool AllowTapRangeSelection + { + get => GetValue(AllowTapRangeSelectionProperty); + set => SetValue(AllowTapRangeSelectionProperty, value); + } + private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs e) { if (IsValidSelectionMode(e.NewValue!)) @@ -470,6 +495,10 @@ namespace Avalonia.Controls SetCurrentValue(SelectedDateProperty, null); _displayDateIsChanging = false; SelectedDates.Clear(); + + // Reset tap range selection state when mode changes + _isTapRangeSelectionActive = false; + _tapRangeStart = null; } else { @@ -477,6 +506,12 @@ namespace Avalonia.Controls } } + private void OnAllowTapRangeSelectionChanged(AvaloniaPropertyChangedEventArgs e) + { + _isTapRangeSelectionActive = false; + _tapRangeStart = null; + } + /// /// Inherited code: Requires comment. /// @@ -1450,6 +1485,94 @@ namespace Avalonia.Controls SelectedDates.AddRange(HoverStart.Value, HoverEnd.Value); } } + + /// + /// Handles tap range selection logic for date range selection. + /// + /// The date that was tapped. + /// True if the tap was handled as part of range selection; otherwise, false. + internal bool ProcessTapRangeSelection(DateTime selectedDate) + { + if (!AllowTapRangeSelection || + (SelectionMode != CalendarSelectionMode.SingleRange && SelectionMode != CalendarSelectionMode.MultipleRange)) + { + return false; + } + + if (!IsValidDateSelection(this, selectedDate)) + { + return false; + } + + if (!_isTapRangeSelectionActive || !_tapRangeStart.HasValue) + { + _isTapRangeSelectionActive = true; + _tapRangeStart = selectedDate; + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + if (!SelectedDates.Contains(selectedDate)) + { + SelectedDates.Add(selectedDate); + } + + return true; + } + else + { + DateTime startDate = _tapRangeStart.Value; + DateTime endDate = selectedDate; + + if (DateTime.Compare(startDate, endDate) > 0) + { + (startDate, endDate) = (endDate, startDate); + } + + CalendarDateRange range = new CalendarDateRange(startDate, endDate); + if (BlackoutDates.ContainsAny(range)) + { + _tapRangeStart = selectedDate; + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + if (!SelectedDates.Contains(selectedDate)) + { + SelectedDates.Add(selectedDate); + } + return true; + } + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + SelectedDates.AddRange(startDate, endDate); + + _isTapRangeSelectionActive = false; + _tapRangeStart = null; + + return true; + } + } private void ProcessSelection(bool shift, DateTime? lastSelectedDate, int? index) { if (SelectionMode == CalendarSelectionMode.None && lastSelectedDate != null) @@ -1457,6 +1580,17 @@ namespace Avalonia.Controls OnDayClick(lastSelectedDate.Value); return; } + + // Handle tap range selection. + if (lastSelectedDate != null && index == null && !shift) + { + if (ProcessTapRangeSelection(lastSelectedDate.Value)) + { + OnDayClick(lastSelectedDate.Value); + return; + } + } + if (lastSelectedDate != null && IsValidKeyboardSelection(this, lastSelectedDate.Value)) { if (SelectionMode == CalendarSelectionMode.SingleRange || SelectionMode == CalendarSelectionMode.MultipleRange) @@ -2069,6 +2203,7 @@ namespace Avalonia.Controls IsTodayHighlightedProperty.Changed.AddClassHandler((x, e) => x.OnIsTodayHighlightedChanged(e)); DisplayModeProperty.Changed.AddClassHandler((x, e) => x.OnDisplayModePropertyChanged(e)); SelectionModeProperty.Changed.AddClassHandler((x, e) => x.OnSelectionModeChanged(e)); + AllowTapRangeSelectionProperty.Changed.AddClassHandler((x, e) => x.OnAllowTapRangeSelectionChanged(e)); SelectedDateProperty.Changed.AddClassHandler((x, e) => x.OnSelectedDateChanged(e)); DisplayDateProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateChanged(e)); DisplayDateStartProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateStartChanged(e)); diff --git a/src/Avalonia.Controls/Calendar/CalendarItem.cs b/src/Avalonia.Controls/Calendar/CalendarItem.cs index c2801e6e64..2bfe3c9be2 100644 --- a/src/Avalonia.Controls/Calendar/CalendarItem.cs +++ b/src/Avalonia.Controls/Calendar/CalendarItem.cs @@ -1030,6 +1030,15 @@ namespace Avalonia.Controls.Primitives Owner.OnDayClick(selectedDate); return; } + if (Owner.AllowTapRangeSelection && + (Owner.SelectionMode == CalendarSelectionMode.SingleRange || Owner.SelectionMode == CalendarSelectionMode.MultipleRange)) + { + if (Owner.ProcessTapRangeSelection(selectedDate)) + { + Owner.OnDayClick(selectedDate); + return; + } + } if (Owner.HoverStart.HasValue) { switch (Owner.SelectionMode) diff --git a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs b/tests/Avalonia.Controls.UnitTests/CalendarTests.cs index 7f464827d7..864f92f511 100644 --- a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CalendarTests.cs @@ -271,5 +271,97 @@ namespace Avalonia.Controls.UnitTests Assert.True(CompareDates(calendar.SelectedDate.Value, DateTime.Today.AddDays(100))); Assert.True(calendar.SelectedDates.Count == 1); } + + [Fact] + public void AllowTapRangeSelection_Should_Disable_TapToSelectRange() + { + var calendar = new Calendar(); + Assert.True(calendar.AllowTapRangeSelection); // Default should be true + + calendar.AllowTapRangeSelection = false; + Assert.False(calendar.AllowTapRangeSelection); + } + + [Fact] + public void TapRangeSelection_Should_Work_In_SingleRange_Mode() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var startDate = new DateTime(2023, 10, 10); + var endDate = new DateTime(2023, 10, 15); + + // First tap should select start date + var firstTapResult = calendar.ProcessTapRangeSelection(startDate); + Assert.True(firstTapResult); + Assert.Equal(1, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(startDate)); + + // Second tap should complete the range + var secondTapResult = calendar.ProcessTapRangeSelection(endDate); + Assert.True(secondTapResult); + Assert.Equal(6, calendar.SelectedDates.Count); // 5 days inclusive + Assert.True(calendar.SelectedDates.Contains(startDate)); + Assert.True(calendar.SelectedDates.Contains(endDate)); + } + + [Fact] + public void TapRangeSelection_Should_Not_Work_In_SingleDate_Mode() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleDate; + calendar.AllowTapRangeSelection = true; + + var date = new DateTime(2023, 10, 10); + var result = calendar.ProcessTapRangeSelection(date); + Assert.False(result); // Should not handle tap range selection + } + + [Fact] + public void TapRangeSelection_Should_Handle_Blackout_Dates() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var startDate = new DateTime(2023, 10, 10); + var blackoutDate = new DateTime(2023, 10, 12); + var endDate = new DateTime(2023, 10, 15); + + // Add blackout date in the middle + calendar.BlackoutDates.Add(new CalendarDateRange(blackoutDate, blackoutDate)); + + // First tap + calendar.ProcessTapRangeSelection(startDate); + Assert.Equal(1, calendar.SelectedDates.Count); + + // Second tap should restart selection due to blackout date + calendar.ProcessTapRangeSelection(endDate); + Assert.Equal(1, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(endDate)); + Assert.False(calendar.SelectedDates.Contains(startDate)); + } + + [Fact] + public void TapRangeSelection_Should_Handle_Reverse_Order_Dates() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var laterDate = new DateTime(2023, 10, 15); + var earlierDate = new DateTime(2023, 10, 10); + + // First tap on later date + calendar.ProcessTapRangeSelection(laterDate); + Assert.Equal(1, calendar.SelectedDates.Count); + + // Second tap on earlier date should still create correct range + calendar.ProcessTapRangeSelection(earlierDate); + Assert.Equal(6, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(earlierDate)); + Assert.True(calendar.SelectedDates.Contains(laterDate)); + } } } From 21b581274640a9355ee6c8e49426d680578e82f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ku=C4=8Dera?= <10546952+miloush@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:33:33 +0100 Subject: [PATCH 012/154] Text API sample app (#19455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * xml documentatation for paragraph properties * TextTestApp * text test files renamed parameter * glyph ink bounds * shaped buffer make selected row visible * text test app force light theme * text test app distinguish start and end distance marks --------- Co-authored-by: Jan Kučera Co-authored-by: Benedikt Stebner Co-authored-by: Julien Lebosquain --- Avalonia.Desktop.slnf | 1 + Avalonia.sln | 7 + samples/TextTestApp/App.axaml | 5 + samples/TextTestApp/App.axaml.cs | 21 + samples/TextTestApp/GridRow.cs | 24 + samples/TextTestApp/InteractiveLineControl.cs | 705 ++++++++++++++++++ samples/TextTestApp/MainWindow.axaml | 105 +++ samples/TextTestApp/MainWindow.axaml.cs | 340 +++++++++ samples/TextTestApp/Program.cs | 25 + samples/TextTestApp/SelectionAdorner.cs | 90 +++ samples/TextTestApp/TextTestApp.csproj | 23 + samples/TextTestApp/app.manifest | 28 + src/Avalonia.Base/Media/CharacterHit.cs | 4 + .../GenericTextParagraphProperties.cs | 85 +-- .../TextFormatting/TextParagraphProperties.cs | 29 +- .../TextFormatting/TextFormatterTests.cs | 24 +- .../Media/TextFormatting/TextLayoutTests.cs | 2 +- 17 files changed, 1440 insertions(+), 78 deletions(-) create mode 100644 samples/TextTestApp/App.axaml create mode 100644 samples/TextTestApp/App.axaml.cs create mode 100644 samples/TextTestApp/GridRow.cs create mode 100644 samples/TextTestApp/InteractiveLineControl.cs create mode 100644 samples/TextTestApp/MainWindow.axaml create mode 100644 samples/TextTestApp/MainWindow.axaml.cs create mode 100644 samples/TextTestApp/Program.cs create mode 100644 samples/TextTestApp/SelectionAdorner.cs create mode 100644 samples/TextTestApp/TextTestApp.csproj create mode 100644 samples/TextTestApp/app.manifest diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 0cddbdf9fd..b021c9f4a5 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -8,6 +8,7 @@ "samples\\ControlCatalog\\ControlCatalog.csproj", "samples\\GpuInterop\\GpuInterop.csproj", "samples\\IntegrationTestApp\\IntegrationTestApp.csproj", + "samples\\TextTestApp\\TextTestApp.csproj", "samples\\MiniMvvm\\MiniMvvm.csproj", "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "samples\\RenderDemo\\RenderDemo.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 8ee6f65989..3103ddeb16 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -191,6 +191,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniMvvm", "samples\MiniMvv EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApp", "samples\IntegrationTestApp\IntegrationTestApp.csproj", "{676D6BFD-029D-4E43-BFC7-3892265CE251}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextTestApp", "samples\TextTestApp\TextTestApp.csproj", "{CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.IntegrationTests.Appium", "tests\Avalonia.IntegrationTests.Appium\Avalonia.IntegrationTests.Appium.csproj", "{F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Browser", "Browser", "{86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}" @@ -538,6 +540,10 @@ Global {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|Any CPU.Build.0 = Debug|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.ActiveCfg = Release|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.Build.0 = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.Build.0 = Release|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -761,6 +767,7 @@ Global {11BE52AF-E2DD-4CF0-B19A-05285ACAF571} = {9B9E3891-2366-4253-A952-D08BCEB71098} {BC594FD5-4AF2-409E-A1E6-04123F54D7C5} = {9B9E3891-2366-4253-A952-D08BCEB71098} {676D6BFD-029D-4E43-BFC7-3892265CE251} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F} = {9B9E3891-2366-4253-A952-D08BCEB71098} {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9} {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270} = {9B9E3891-2366-4253-A952-D08BCEB71098} diff --git a/samples/TextTestApp/App.axaml b/samples/TextTestApp/App.axaml new file mode 100644 index 0000000000..ff984071c1 --- /dev/null +++ b/samples/TextTestApp/App.axaml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/TextTestApp/App.axaml.cs b/samples/TextTestApp/App.axaml.cs new file mode 100644 index 0000000000..ef6cc15ddf --- /dev/null +++ b/samples/TextTestApp/App.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace TextTestApp +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/TextTestApp/GridRow.cs b/samples/TextTestApp/GridRow.cs new file mode 100644 index 0000000000..d486e186db --- /dev/null +++ b/samples/TextTestApp/GridRow.cs @@ -0,0 +1,24 @@ +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace TextTestApp +{ + public class GridRow : Grid + { + protected override void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + base.ChildrenChanged(sender, e); + + while (Children.Count > ColumnDefinitions.Count) + ColumnDefinitions.Add(new ColumnDefinition { SharedSizeGroup = "c" + ColumnDefinitions.Count }); + + for (int i = 0; i < Children.Count; i++) + { + SetColumn(Children[i], i); + if (Children[i] is Layoutable l) + l.VerticalAlignment = VerticalAlignment.Center; + } + } + } +} diff --git a/samples/TextTestApp/InteractiveLineControl.cs b/samples/TextTestApp/InteractiveLineControl.cs new file mode 100644 index 0000000000..67db521ef4 --- /dev/null +++ b/samples/TextTestApp/InteractiveLineControl.cs @@ -0,0 +1,705 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public class InteractiveLineControl : Control + { + /// + /// Defines the property. + /// + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BackgroundProperty = + Border.BackgroundProperty.AddOwner(); + + public static readonly StyledProperty ExtentStrokeProperty = + AvaloniaProperty.Register(nameof(ExtentStroke)); + + public static readonly StyledProperty BaselineStrokeProperty = + AvaloniaProperty.Register(nameof(BaselineStroke)); + + public static readonly StyledProperty TextBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(TextBoundsStroke)); + + public static readonly StyledProperty RunBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(RunBoundsStroke)); + + public static readonly StyledProperty NextHitStrokeProperty = + AvaloniaProperty.Register(nameof(NextHitStroke)); + + public static readonly StyledProperty BackspaceHitStrokeProperty = + AvaloniaProperty.Register(nameof(BackspaceHitStroke)); + + public static readonly StyledProperty PreviousHitStrokeProperty = + AvaloniaProperty.Register(nameof(PreviousHitStroke)); + + public static readonly StyledProperty DistanceStrokeProperty = + AvaloniaProperty.Register(nameof(DistanceStroke)); + + public IBrush? ExtentStroke + { + get => GetValue(ExtentStrokeProperty); + set => SetValue(ExtentStrokeProperty, value); + } + public IBrush? BaselineStroke + { + get => GetValue(BaselineStrokeProperty); + set => SetValue(BaselineStrokeProperty, value); + } + + public IBrush? TextBoundsStroke + { + get => GetValue(TextBoundsStrokeProperty); + set => SetValue(TextBoundsStrokeProperty, value); + } + + public IBrush? RunBoundsStroke + { + get => GetValue(RunBoundsStrokeProperty); + set => SetValue(RunBoundsStrokeProperty, value); + } + + public IBrush? NextHitStroke + { + get => GetValue(NextHitStrokeProperty); + set => SetValue(NextHitStrokeProperty, value); + } + + public IBrush? BackspaceHitStroke + { + get => GetValue(BackspaceHitStrokeProperty); + set => SetValue(BackspaceHitStrokeProperty, value); + } + + public IBrush? PreviousHitStroke + { + get => GetValue(PreviousHitStrokeProperty); + set => SetValue(PreviousHitStrokeProperty, value); + } + + public IBrush? DistanceStroke + { + get => GetValue(DistanceStrokeProperty); + set => SetValue(DistanceStrokeProperty, value); + } + + private IPen? _extentPen; + protected IPen ExtentPen => _extentPen ??= new Pen(ExtentStroke, dashStyle: DashStyle.Dash); + + private IPen? _baselinePen; + protected IPen BaselinePen => _baselinePen ??= new Pen(BaselineStroke); + + private IPen? _textBoundsPen; + protected IPen TextBoundsPen => _textBoundsPen ??= new Pen(TextBoundsStroke); + + private IPen? _runBoundsPen; + protected IPen RunBoundsPen => _runBoundsPen ??= new Pen(RunBoundsStroke, dashStyle: DashStyle.Dash); + + private IPen? _nextHitPen; + protected IPen NextHitPen => _nextHitPen ??= new Pen(NextHitStroke); + + private IPen? _previousHitPen; + protected IPen PreviousHitPen => _previousHitPen ??= new Pen(PreviousHitStroke); + + private IPen? _backspaceHitPen; + protected IPen BackspaceHitPen => _backspaceHitPen ??= new Pen(BackspaceHitStroke); + + private IPen? _distancePen; + protected IPen DistancePen => _distancePen ??= new Pen(DistanceStroke); + + /// + /// Gets or sets the text to draw. + /// + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets or sets a brush used to paint the control's background. + /// + public IBrush? Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + // TextRunProperties + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFamilyProperty = + TextElement.FontFamilyProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFeaturesProperty = + TextElement.FontFeaturesProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontSizeProperty = + TextElement.FontSizeProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStyleProperty = + TextElement.FontStyleProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontWeightProperty = + TextElement.FontWeightProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStretchProperty = + TextElement.FontStretchProperty.AddOwner(); + + /// + /// Gets or sets the font family used to draw the control's text. + /// + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + /// + /// Gets or sets the font features turned on/off. + /// + public FontFeatureCollection? FontFeatures + { + get => GetValue(FontFeaturesProperty); + set => SetValue(FontFeaturesProperty, value); + } + + /// + /// Gets or sets the size of the control's text in points. + /// + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// + /// Gets or sets the font style used to draw the control's text. + /// + public FontStyle FontStyle + { + get => GetValue(FontStyleProperty); + set => SetValue(FontStyleProperty, value); + } + + /// + /// Gets or sets the font weight used to draw the control's text. + /// + public FontWeight FontWeight + { + get => GetValue(FontWeightProperty); + set => SetValue(FontWeightProperty, value); + } + + /// + /// Gets or sets the font stretch used to draw the control's text. + /// + public FontStretch FontStretch + { + get => GetValue(FontStretchProperty); + set => SetValue(FontStretchProperty, value); + } + + private GenericTextRunProperties? _textRunProperties; + public GenericTextRunProperties TextRunProperties + { + get + { + return _textRunProperties ??= CreateTextRunProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textRunProperties = value; + SetCurrentValue(FontFamilyProperty, value.Typeface.FontFamily); + SetCurrentValue(FontFeaturesProperty, value.FontFeatures); + SetCurrentValue(FontSizeProperty, value.FontRenderingEmSize); + SetCurrentValue(FontStyleProperty, value.Typeface.Style); + SetCurrentValue(FontWeightProperty, value.Typeface.Weight); + SetCurrentValue(FontStretchProperty, value.Typeface.Stretch); + } + } + + private GenericTextRunProperties CreateTextRunProperties() + { + Typeface typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + return new GenericTextRunProperties(typeface, FontFeatures, FontSize, + textDecorations: null, + foregroundBrush: Brushes.Black, + backgroundBrush: null, + baselineAlignment: BaselineAlignment.Baseline, + cultureInfo: null); + } + + // TextParagraphProperties + + private GenericTextParagraphProperties? _textParagraphProperties; + public GenericTextParagraphProperties TextParagraphProperties + { + get + { + return _textParagraphProperties ??= CreateTextParagraphProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textParagraphProperties = null; + SetCurrentValue(FlowDirectionProperty, value.FlowDirection); + } + } + + private GenericTextParagraphProperties CreateTextParagraphProperties() + { + return new GenericTextParagraphProperties( + FlowDirection, + TextAlignment.Start, + firstLineInParagraph: false, + alwaysCollapsible: false, + TextRunProperties, + textWrapping: TextWrapping.NoWrap, + lineHeight: 0, + indent: 0, + letterSpacing: 0); + } + + private readonly ITextSource _textSource; + private class TextSource : ITextSource + { + private readonly InteractiveLineControl _owner; + + public TextSource(InteractiveLineControl owner) + { + _owner = owner; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + string text = _owner.Text ?? string.Empty; + + if (textSourceIndex < 0 || textSourceIndex >= text.Length) + return null; + + return new TextCharacters(text, _owner.TextRunProperties); + } + } + + private TextLine? _textLine; + public TextLine? TextLine => _textLine ??= TextFormatter.Current.FormatLine(_textSource, 0, Bounds.Size.Width, TextParagraphProperties); + + private TextLayout? _textLayout; + public TextLayout TextLayout => _textLayout ??= new TextLayout(_textSource, TextParagraphProperties); + + private Size? _textLineSize; + protected Size TextLineSize => _textLineSize ??= TextLine is { } textLine ? new Size(textLine.WidthIncludingTrailingWhitespace, textLine.Height) : default; + + private Size? _inkSize; + protected Size InkSize => _inkSize ??= TextLine is { } textLine ? new Size(textLine.OverhangLeading + textLine.WidthIncludingTrailingWhitespace + textLine.OverhangTrailing, textLine.Extent) : default; + + public event EventHandler? TextLineChanged; + + public InteractiveLineControl() + { + _textSource = new TextSource(this); + + RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); + RenderOptions.SetTextRenderingMode(this, TextRenderingMode.SubpixelAntialias); + } + + private void InvalidateTextRunProperties() + { + _textRunProperties = null; + InvalidateTextParagraphProperties(); + } + + private void InvalidateTextParagraphProperties() + { + _textParagraphProperties = null; + InvalidateTextLine(); + } + + private void InvalidateTextLine() + { + _textLayout = null; + _textLine = null; + _textLineSize = null; + _inkSize = null; + InvalidateMeasure(); + InvalidateVisual(); + + TextLineChanged?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + switch (change.Property.Name) + { + case nameof(FontFamily): + case nameof(FontSize): + InvalidateTextRunProperties(); + break; + + case nameof(FontStyle): + case nameof(FontWeight): + case nameof(FontStretch): + InvalidateTextRunProperties(); + break; + + case nameof(FlowDirection): + InvalidateTextParagraphProperties(); + break; + + case nameof(Text): + InvalidateTextLine(); + break; + + case nameof(BaselineStroke): + _baselinePen = null; + InvalidateVisual(); + break; + + case nameof(TextBoundsStroke): + _textBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(RunBoundsStroke): + _runBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(NextHitStroke): + _nextHitPen = null; + InvalidateVisual(); + break; + + case nameof(PreviousHitStroke): + _previousHitPen = null; + InvalidateVisual(); + break; + + case nameof(BackspaceHitStroke): + _backspaceHitPen = null; + InvalidateVisual(); + break; + } + + base.OnPropertyChanged(change); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (TextLine == null) + return default; + + return new Size(Math.Max(TextLineSize.Width, InkSize.Width), Math.Max(TextLineSize.Height, InkSize.Height)); + } + + private const double VerticalSpacing = 5; + private const double HorizontalSpacing = 5; + private const double ArrowSize = 5; + + private Dictionary _labelsCache = new(); + protected FormattedText GetOrCreateLabel(string label, IBrush brush, bool disableCache = false) + { + if (_labelsCache.TryGetValue(label, out var text)) + return text; + + text = new FormattedText(label, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, Typeface.Default, 8, brush); + + if (!disableCache) + _labelsCache[label] = text; + + return text; + } + + private Rect _inkRenderBounds; + private Rect _lineRenderBounds; + + public Rect InkRenderBounds => _inkRenderBounds; + public Rect LineRenderBounds => _lineRenderBounds; + + public override void Render(DrawingContext context) + { + TextLine? textLine = TextLine; + if (textLine == null) + return; + + // overhang leading should be negative when extending (e.g. for j) WPF: "When the leading alignment point comes before the leading drawn pixel, the value is negative." - docs wrong but values correct + // overhang trailing should be negative when extending (e.g. for f) WPF: "The OverhangTrailing value will be positive when the trailing drawn pixel comes before the trailing alignment point." + // overhang after should be negative when inside (e.g. for x) WPF: "The value is positive if the bottommost drawn pixel goes below the line bottom, and is negative if it is within (on or above) the line." + // => we want overhang before to be negative when inside (e.g. for x) + + double overhangBefore = textLine.Extent - textLine.OverhangAfter - textLine.Height; + Rect inkBounds = new Rect(new Point(textLine.OverhangLeading, -overhangBefore), InkSize); + Rect lineBounds = new Rect(new Point(0, 0), TextLineSize); + + if (inkBounds.Left < 0) + lineBounds = lineBounds.Translate(new Vector(-inkBounds.Left, 0)); + + if (inkBounds.Top < 0) + lineBounds = lineBounds.Translate(new Vector(0, -inkBounds.Top)); + + _inkRenderBounds = inkBounds; + _lineRenderBounds = lineBounds; + + Rect bounds = new Rect(0, 0, Math.Max(inkBounds.Right, lineBounds.Right), Math.Max(inkBounds.Bottom, lineBounds.Bottom)); + double labelX = bounds.Right + HorizontalSpacing; + + if (Background is IBrush background) + context.FillRectangle(background, lineBounds); + + if (ExtentStroke != null) + { + context.DrawRectangle(ExtentPen, inkBounds); + RenderLabel(context, nameof(textLine.Extent), ExtentStroke, labelX, inkBounds.Top); + } + + using (context.PushTransform(Matrix.CreateTranslation(lineBounds.Left, lineBounds.Top))) + { + labelX -= lineBounds.Left; // labels to ignore horizontal transform + + if (BaselineStroke != null) + { + RenderFontLine(context, textLine.Baseline, lineBounds.Width, BaselinePen); // no other lines currently available in Avalonia + RenderLabel(context, nameof(textLine.Baseline), BaselineStroke, labelX, textLine.Baseline); + } + + textLine.Draw(context, lineOrigin: default); + + var runBoundsStroke = RunBoundsStroke; + if (TextBoundsStroke != null || runBoundsStroke != null) + { + IReadOnlyList textBounds = textLine.GetTextBounds(textLine.FirstTextSourceIndex, textLine.Length); + foreach (var textBound in textBounds) + { + if (runBoundsStroke != null) + { + var runBounds = textBound.TextRunBounds; + foreach (var runBound in runBounds) + context.DrawRectangle(RunBoundsPen, runBound.Rectangle); + } + + context.DrawRectangle(TextBoundsPen, textBound.Rectangle); + } + } + + double y = inkBounds.Bottom - lineBounds.Top + VerticalSpacing * 2; + + if (NextHitStroke != null) + { + RenderHits(context, NextHitPen, textLine, textLine.GetNextCaretCharacterHit, new CharacterHit(0), ref y); + RenderLabel(context, nameof(textLine.GetNextCaretCharacterHit), NextHitStroke, labelX, y); + y += VerticalSpacing * 2; + } + + if (PreviousHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetPreviousCaretCharacterHit), PreviousHitStroke, labelX, y); + RenderHits(context, PreviousHitPen, textLine, textLine.GetPreviousCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (BackspaceHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetBackspaceCaretCharacterHit), BackspaceHitStroke, labelX, y); + RenderHits(context, BackspaceHitPen, textLine, textLine.GetBackspaceCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (DistanceStroke != null) + { + y += VerticalSpacing; + + var label = RenderLabel(context, nameof(textLine.GetDistanceFromCharacterHit), DistanceStroke, 0, y); + y += label.Height; + + for (int i = 0; i < textLine.Length; i++) + { + var hit = new CharacterHit(i); + CharacterHit prevHit = default, nextHit = default; + + double leftLabelX = -HorizontalSpacing; + + // we want z-order to be previous, next, distance + // but labels need to be ordered next, distance, previous + if (NextHitStroke != null) + { + nextHit = textLine.GetNextCaretCharacterHit(hit); + var nextLabel = RenderLabel(context, $" > {nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}", NextHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + leftLabelX -= nextLabel.WidthIncludingTrailingWhitespace; + } + + if (PreviousHitStroke != null) + { + prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex + prevHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, PreviousHitPen, ArrowSize); + } + + if (NextHitStroke != null) + { + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex + nextHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, NextHitPen, ArrowSize); + } + + label = RenderLabel(context, $"[{i}]", DistanceStroke, leftLabelX, y, TextAlignment.Right); + leftLabelX -= label.WidthIncludingTrailingWhitespace; + + if (PreviousHitStroke != null) + RenderLabel(context, $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength} < ", PreviousHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + + double distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(i)); + RenderHorizontalBar(context, 0, distance, y, DistancePen, ArrowSize); + //RenderLabel(context, distance.ToString("F2"), DistanceStroke, distance + HorizontalSpacing, y, disableCache: true); + + y += label.Height; + } + } + } + } + + [return: NotNullIfNotNull("brush")] + private FormattedText? RenderLabel(DrawingContext context, string label, IBrush? brush, double x, double y, TextAlignment alignment = TextAlignment.Left, bool disableCache = false) + { + if (brush == null) + return null; + + var text = GetOrCreateLabel(label, brush, disableCache); + + if (alignment == TextAlignment.Right) + context.DrawText(text, new Point(x - text.WidthIncludingTrailingWhitespace, y - text.Height / 2)); + else + context.DrawText(text, new Point(x, y - text.Height / 2)); + + return text; + } + + private void RenderHits(DrawingContext context, IPen hitPen, TextLine textLine, Func nextHit, CharacterHit startingHit, ref double y) + { + CharacterHit lastHit = startingHit; + double lastX = textLine.GetDistanceFromCharacterHit(lastHit); + double lastDirection = 0; + y -= VerticalSpacing; // we always start with adding one below + + while (true) + { + CharacterHit hit = nextHit(lastHit); + if (hit == lastHit) + break; + + double x = textLine.GetDistanceFromCharacterHit(hit); + double direction = Math.Sign(x - lastX); + + if (direction == 0 || lastDirection != direction) + y += VerticalSpacing; + + if (direction == 0) + RenderPoint(context, x, y, hitPen, ArrowSize); + else + RenderHorizontalArrow(context, lastX, x, y, hitPen, ArrowSize); + + lastX = x; + lastHit = hit; + lastDirection = direction; + } + } + + private void RenderPoint(DrawingContext context, double x, double y, IPen pen, double arrowHeight) + { + context.DrawEllipse(pen.Brush, pen, new Point(x, y), ArrowSize / 2, ArrowSize / 2); + } + + private void RenderHorizontalPoint(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + PathGeometry startCap = new PathGeometry(); + PathFigure startFigure = new PathFigure(); + startFigure.StartPoint = new Point(xStart, y - size / 2); + startFigure.IsClosed = true; + startFigure.IsFilled = true; + startFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xStart, y + size / 2), SweepDirection = SweepDirection.CounterClockwise }); + startCap.Figures!.Add(startFigure); + + context.DrawGeometry(pen.Brush, pen, startCap); + + PathGeometry endCap = new PathGeometry(); + PathFigure endFigure = new PathFigure(); + endFigure.StartPoint = new Point(xEnd, y - size / 2); + endFigure.IsClosed = true; + endFigure.IsFilled = false; + endFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xEnd, y + size / 2), SweepDirection = SweepDirection.Clockwise }); + endCap.Figures!.Add(endFigure); + + context.DrawGeometry(pen.Brush, pen, endCap); + } + + private void RenderHorizontalArrow(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + + if (xEnd >= xStart) + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd - size, y - size / 2), + new Point(xEnd - size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + else + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd + size, y - size / 2), + new Point(xEnd + size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + } + private void RenderHorizontalBar(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + context.DrawLine(pen, new Point(xEnd, y - size / 2), new Point(xEnd, y + size / 2)); // end cap + } + + private void RenderFontLine(DrawingContext context, double y, double width, IPen pen) + { + context.DrawLine(pen, new Point(0, y), new Point(width, y)); + } + } +} diff --git a/samples/TextTestApp/MainWindow.axaml b/samples/TextTestApp/MainWindow.axaml new file mode 100644 index 0000000000..6dd6670124 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/MainWindow.axaml.cs b/samples/TextTestApp/MainWindow.axaml.cs new file mode 100644 index 0000000000..493bc3e9d4 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public partial class MainWindow : Window + { + private SelectionAdorner? _selectionAdorner; + + public MainWindow() + { + InitializeComponent(); + + _selectionAdorner = new(); + _selectionAdorner.Stroke = Brushes.Red; + _selectionAdorner.Fill = new SolidColorBrush(Colors.LightSkyBlue, 0.25); + _selectionAdorner.IsHitTestVisible = false; + AdornerLayer.SetIsClipEnabled(_selectionAdorner, false); + AdornerLayer.SetAdorner(_rendering, _selectionAdorner); + + _rendering.TextLineChanged += OnShapeBufferChanged; + OnShapeBufferChanged(); + } + + private void OnNewWindowClick(object? sender, RoutedEventArgs e) + { + MainWindow win = new MainWindow(); + win.Show(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.F5) + { + _rendering.InvalidateVisual(); + OnShapeBufferChanged(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + if (_hits.IsKeyboardFocusWithin && _hits.SelectedIndex != -1) + { + _hits.SelectedIndex = -1; + e.Handled = true; + } + else if (_buffer.IsKeyboardFocusWithin && _buffer.SelectedIndex != -1) + { + _buffer.SelectedIndex = -1; + e.Handled = true; + } + } + + base.OnKeyDown(e); + } + + private void OnShapeBufferChanged(object? sender, EventArgs e) => OnShapeBufferChanged(); + private void OnShapeBufferChanged() + { + if (_selectionAdorner == null) + return; + + ListBuffers(); + ListHits(); + + Rect bounds = _rendering.LineRenderBounds; + _selectionAdorner!.Transform = Matrix.CreateTranslation(bounds.X, bounds.Y); + } + + private void ListBuffers() + { + for (int i = _buffer.ItemCount - 1; i >= 1; i--) + _buffer.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + double currentX = _rendering.LineRenderBounds.Left; + foreach (TextRun run in textLine.TextRuns) + { + if (run is ShapedTextRun shapedRun) + { + _buffer.Items.Add(new TextBlock + { + Text = $"{run.GetType().Name}: Bidi = {shapedRun.BidiLevel}, Font = {shapedRun.ShapedBuffer.GlyphTypeface.FamilyName}", + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run, + }); + + ListBuffer(textLine, shapedRun, ref currentX); + } + else + _buffer.Items.Add(new TextBlock + { + Text = run.GetType().Name, + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run + }); + } + } + + private void ListHits() + { + for (int i = _hits.ItemCount - 1; i >= 1; i--) + _hits.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + for (int i = 0; i < textLine.Length; i++) + { + string? clusterText = _rendering.Text!.Substring(i, 1); + string? clusterHex = ToHex(clusterText); + + var hit = new CharacterHit(i); + var prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var nextHit = textLine.GetNextCaretCharacterHit(hit); + var bkspHit = textLine.GetBackspaceCaretCharacterHit(hit); + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = $"{bkspHit.FirstCharacterIndex}+{bkspHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = i.ToString(), FontWeight = FontWeight.Bold }); + row.Children.Add(new TextBlock { Text = $"{nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = clusterHex }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = textLine.GetDistanceFromCharacterHit(hit).ToString() }); + row.Tag = i; + + _hits.Items.Add(row); + } + } + + private static readonly IBrush TransparentAliceBlue = new SolidColorBrush(0x0F0188FF); + private static readonly IBrush TransparentAntiqueWhite = new SolidColorBrush(0x28DF8000); + private void ListBuffer(TextLine textLine, ShapedTextRun shapedRun, ref double currentX) + { + ShapedBuffer buffer = shapedRun.ShapedBuffer; + + int lastClusterStart = -1; + bool oddCluster = false; + + IReadOnlyList glyphInfos = buffer; + + currentX += shapedRun.GlyphRun.BaselineOrigin.X; + for (var i = 0; i < glyphInfos.Count; i++) + { + GlyphInfo info = glyphInfos[i]; + int clusterStart = info.GlyphCluster; + int clusterLength = FindClusterLenghtAt(i); + string? clusterText = _rendering.Text!.Substring(clusterStart, clusterLength); + string? clusterHex = ToHex(clusterText); + + Border border = new Border(); + if (clusterStart == lastClusterStart) + { + clusterText = clusterHex = null; + } + else + { + oddCluster = !oddCluster; + lastClusterStart = clusterStart; + } + border.Background = oddCluster ? TransparentAliceBlue : TransparentAntiqueWhite; + + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = clusterStart.ToString() }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = clusterHex, TextWrapping = TextWrapping.Wrap }); + row.Children.Add(new Image { Source = CreateGlyphDrawing(shapedRun.GlyphRun.GlyphTypeface, FontSize, info), Margin = new Thickness(2) }); + row.Children.Add(new TextBlock { Text = info.GlyphIndex.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphAdvance.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphOffset.ToString() }); + + Geometry glyph = GetGlyphOutline(shapedRun.GlyphRun.GlyphTypeface, shapedRun.GlyphRun.FontRenderingEmSize, info); + Rect glyphBounds = glyph.Bounds; + Rect offsetBounds = glyphBounds.Translate(new Vector(currentX + info.GlyphOffset.X, info.GlyphOffset.Y)); + + TextBlock boundsBlock = new TextBlock { Text = offsetBounds.ToString() }; + ToolTip.SetTip(boundsBlock, "Origin bounds: " + glyphBounds); + row.Children.Add(boundsBlock); + + border.Child = row; + border.Tag = offsetBounds; + _buffer.Items.Add(border); + + currentX += glyphInfos[i].GlyphAdvance; + } + + int FindClusterLenghtAt(int index) + { + int cluster = glyphInfos[index].GlyphCluster; + if (shapedRun.BidiLevel % 2 == 0) + { + while (++index < glyphInfos.Count) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[0].GlyphCluster - cluster; + } + else + { + while (--index >= 0) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[glyphInfos.Count - 1].GlyphCluster - cluster; + } + } + } + + private IImage CreateGlyphDrawing(IGlyphTypeface glyphTypeface, double emSize, GlyphInfo info) + { + return new DrawingImage { Drawing = new GeometryDrawing { Brush = Brushes.Black, Geometry = GetGlyphOutline(glyphTypeface, emSize, info) } }; + } + + private Geometry GetGlyphOutline(IGlyphTypeface typeface, double emSize, GlyphInfo info) + { + // substitute for GlyphTypeface.GetGlyphOutline + return new GlyphRun(typeface, emSize, new[] { '\0' }, [info]).BuildGeometry(); + } + + private void OnPointerMoved(object sender, PointerEventArgs e) + { + InteractiveLineControl lineControl = (InteractiveLineControl)sender; + TextLayout textLayout = lineControl.TextLayout; + Rect lineBounds = lineControl.LineRenderBounds; + + PointerPoint pointerPoint = e.GetCurrentPoint(lineControl); + Point point = new Point(pointerPoint.Position.X - lineBounds.Left, pointerPoint.Position.Y - lineBounds.Top); + _coordinates.Text = $"{pointerPoint.Position.X:F4}, {pointerPoint.Position.Y:F4}"; + + TextHitTestResult textHit = textLayout.HitTestPoint(point); + _hit.Text = $"{textHit.TextPosition} ({textHit.CharacterHit.FirstCharacterIndex}+{textHit.CharacterHit.TrailingLength})"; + if (textHit.IsTrailing) + _hit.Text += " T"; + + if (textHit.IsInside) + { + _hits.SelectedIndex = textHit.TextPosition + 1; // header + } + else + _hits.SelectedIndex = -1; + } + + private void OnHitTestMethodChanged(object? sender, RoutedEventArgs e) + { + _hits.SelectionMode = _hitRangeToggle.IsChecked == true ? SelectionMode.Multiple : SelectionMode.Single; + } + + private void OnHitsSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_selectionAdorner == null) + return; + + List rectangles = new List(); + TextLayout textLayout = _rendering.TextLayout; + + if (_hitRangeToggle.IsChecked == true) + { + // collect continuous selected indices + List<(int start, int length)> selections = new(1); + + int[] indices = _hits.Selection.SelectedIndexes.ToArray(); + Array.Sort(indices); + + int currentIndex = -1; + int currentLength = 0; + for (int i = 0; i < indices.Length; i++) + if (_hits.Items[indices[i]] is Control { Tag: int index }) + { + if (index == currentIndex + currentLength) + { + currentLength++; + } + else + { + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + currentIndex = index; + currentLength = 1; + } + } + + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + foreach (var selection in selections) + { + var selectionRectangles = textLayout.HitTestTextRange(selection.start, selection.length); + rectangles.AddRange(selectionRectangles); + } + } + else + { + if (_hits.SelectedItem is Control { Tag: int index }) + { + Rect rect = textLayout.HitTestTextPosition(index); + rectangles.Add(rect); + } + } + + _selectionAdorner.Rectangles = rectangles; + } + + private void OnBufferSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + List rectangles = new List(_buffer.Selection.Count); + + foreach (var row in _buffer.SelectedItems) + if (row is Control { Tag: Rect rect }) + rectangles.Add(rect); + + _selectionAdorner.Rectangles = rectangles; + } + + private static string ToHex(string s) + { + if (string.IsNullOrEmpty(s)) + return s; + + return string.Join(" ", s.Select(c => ((int)c).ToString("X4"))); + } + } +} diff --git a/samples/TextTestApp/Program.cs b/samples/TextTestApp/Program.cs new file mode 100644 index 0000000000..cb953f8ba5 --- /dev/null +++ b/samples/TextTestApp/Program.cs @@ -0,0 +1,25 @@ +using System; +using Avalonia; + +namespace TextTestApp +{ + static class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } + } +} diff --git a/samples/TextTestApp/SelectionAdorner.cs b/samples/TextTestApp/SelectionAdorner.cs new file mode 100644 index 0000000000..bfaa030fc8 --- /dev/null +++ b/samples/TextTestApp/SelectionAdorner.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace TextTestApp +{ + public class SelectionAdorner : Control + { + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register(nameof(Fill)); + + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register(nameof(Stroke)); + + public static readonly StyledProperty TransformProperty = + AvaloniaProperty.Register(nameof(Transform), Matrix.Identity); + + public Matrix Transform + { + get => this.GetValue(TransformProperty); + set => SetValue(TransformProperty, value); + } + + public IBrush? Stroke + { + get => GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + public IBrush? Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + private IList? _rectangles; + public IList? Rectangles + { + get => _rectangles; + set + { + _rectangles = value; + InvalidateVisual(); + } + } + + public SelectionAdorner() + { + AffectsRender(FillProperty, StrokeProperty, TransformProperty); + } + + public override void Render(DrawingContext context) + { + var rectangles = Rectangles; + if (rectangles == null) + return; + + using (context.PushTransform(Transform)) + { + Pen pen = new Pen(Stroke, 1); + for (int i = 0; i < rectangles.Count; i++) + { + Rect rectangle = rectangles[i]; + Rect normalized = rectangle.Width < 0 ? new Rect(rectangle.TopRight, rectangle.BottomLeft) : rectangle; + + if (rectangles[i].Width == 0) + context.DrawLine(pen, rectangle.TopLeft, rectangle.BottomRight); + else + context.DrawRectangle(Fill, pen, normalized); + + RenderCue(context, pen, rectangle.TopLeft, 5, isFilled: true); + RenderCue(context, pen, rectangle.TopRight, 5, isFilled: false); + } + } + } + + private void RenderCue(DrawingContext context, IPen pen, Point p, double size, bool isFilled) + { + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(p.X - size / 2, p.Y - size), + new Point(p.X + size / 2, p.Y - size), + new Point(p.X, p.Y), + new Point(p.X - size / 2, p.Y - size), + ], isFilled)); + } + } +} diff --git a/samples/TextTestApp/TextTestApp.csproj b/samples/TextTestApp/TextTestApp.csproj new file mode 100644 index 0000000000..50dc52c768 --- /dev/null +++ b/samples/TextTestApp/TextTestApp.csproj @@ -0,0 +1,23 @@ + + + + WinExe + $(AvsCurrentTargetFramework) + true + app.manifest + true + enable + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/app.manifest b/samples/TextTestApp/app.manifest new file mode 100644 index 0000000000..db90057191 --- /dev/null +++ b/samples/TextTestApp/app.manifest @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Base/Media/CharacterHit.cs b/src/Avalonia.Base/Media/CharacterHit.cs index 27cf3a42dc..48b89c0543 100644 --- a/src/Avalonia.Base/Media/CharacterHit.cs +++ b/src/Avalonia.Base/Media/CharacterHit.cs @@ -35,6 +35,10 @@ namespace Avalonia.Media /// /// Gets the trailing length value for the character that got hit. /// + /// + /// In the case of a leading edge, this value is 0. In the case of a trailing edge, + /// this value is the number of code points until the next valid caret position. + /// public int TrailingLength { get; } public bool Equals(CharacterHit other) diff --git a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs index b9ed31523e..15b3d5a9b4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs @@ -1,7 +1,7 @@ namespace Avalonia.Media.TextFormatting { /// - /// Generic implementation of TextParagraphProperties + /// Generic implementation of . /// public sealed class GenericTextParagraphProperties : TextParagraphProperties { @@ -11,45 +11,45 @@ private double _lineHeight; /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// default paragraph's default run properties - /// logical horizontal alignment - /// text wrap option - /// Paragraph line height - /// letter spacing + /// Default text run properties, such as typeface or foreground brush. + /// The alignment of inline content in a block. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of letter spacing. public GenericTextParagraphProperties(TextRunProperties defaultTextRunProperties, TextAlignment textAlignment = TextAlignment.Left, - TextWrapping textWrap = TextWrapping.NoWrap, + TextWrapping textWrapping = TextWrapping.NoWrap, double lineHeight = 0, double letterSpacing = 0) { DefaultTextRunProperties = defaultTextRunProperties; _textAlignment = textAlignment; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; } /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// text flow direction - /// logical horizontal alignment - /// true if the paragraph is the first line in the paragraph - /// true if the line is always collapsible - /// default paragraph's default run properties - /// text wrap option - /// Paragraph line height - /// line indentation - /// letter spacing + /// The primary text advance direction. + /// The alignment of inline content in a block. + /// if the paragraph is the first line in the paragraph + /// if the formatted line may always be collapsed. If (the default), only lines that overflow the paragraph width are collapsed. + /// Default text run properties, such as typeface or foreground brush. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of line indentation. + /// The amount of letter spacing. public GenericTextParagraphProperties( FlowDirection flowDirection, TextAlignment textAlignment, bool firstLineInParagraph, bool alwaysCollapsible, TextRunProperties defaultTextRunProperties, - TextWrapping textWrap, + TextWrapping textWrapping, double lineHeight, double indent, double letterSpacing) @@ -59,16 +59,16 @@ FirstLineInParagraph = firstLineInParagraph; AlwaysCollapsible = alwaysCollapsible; DefaultTextRunProperties = defaultTextRunProperties; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; Indent = indent; } /// - /// Constructing TextParagraphProperties from another one + /// Initializes a new instance of the with values copied from the specified . /// - /// source line props + /// The to copy values from. public GenericTextParagraphProperties(TextParagraphProperties textParagraphProperties) : this(textParagraphProperties.FlowDirection, textParagraphProperties.TextAlignment, @@ -82,64 +82,43 @@ { } - /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left, or top-to-bottom. - /// + /// public override FlowDirection FlowDirection { get { return _flowDirection; } } - /// - /// This property describes how inline content of a block is aligned. - /// + /// public override TextAlignment TextAlignment { get { return _textAlignment; } } - /// - /// Paragraph's line height - /// + /// public override double LineHeight { get { return _lineHeight; } } - /// - /// Indicates the first line of the paragraph. - /// + /// public override bool FirstLineInParagraph { get; } - /// - /// If true, the formatted line may always be collapsed. If false (the default), - /// only lines that overflow the paragraph width are collapsed. - /// + /// public override bool AlwaysCollapsible { get; } - /// - /// Paragraph's default run properties - /// + /// public override TextRunProperties DefaultTextRunProperties { get; } - /// - /// This property controls whether or not text wraps when it reaches the flow edge - /// of its containing block box - /// + /// public override TextWrapping TextWrapping { get { return _textWrap; } } - /// - /// Line indentation - /// + /// public override double Indent { get; } - /// - /// The letter spacing - /// + /// public override double LetterSpacing { get; } /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs index c41d9552ca..cde63c02a6 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs @@ -6,63 +6,68 @@ public abstract class TextParagraphProperties { /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left. + /// Gets a value that specifies whether the primary text advance direction shall be left-to-right, or right-to-left. /// public abstract FlowDirection FlowDirection { get; } /// - /// Gets the text alignment. + /// Gets a value that describes how an inline content of a block is aligned. /// public abstract TextAlignment TextAlignment { get; } /// - /// Paragraph's line height + /// Gets the height of a line of text. /// public abstract double LineHeight { get; } /// - /// Paragraph's line spacing + /// Gets or sets paragraph's line spacing. /// internal double LineSpacing { get; set; } /// - /// Indicates the first line of the paragraph. + /// Gets a value that indicates whether the text run is the first line of the paragraph. /// public abstract bool FirstLineInParagraph { get; } /// + /// Gets a value that indicates whether a formatted line can always be collapsed. + /// + /// /// If true, the formatted line may always be collapsed. If false (the default), /// only lines that overflow the paragraph width are collapsed. - /// + /// public virtual bool AlwaysCollapsible { get { return false; } } /// - /// Gets the default text style. + /// Gets the default text run properties, such as typeface or foreground brush. /// public abstract TextRunProperties DefaultTextRunProperties { get; } /// + /// Gets the collection of TextDecoration objects. + /// + /// /// If not null, text decorations to apply to all runs in the line. This is in addition /// to any text decorations specified by the TextRunProperties for individual text runs. /// public virtual TextDecorationCollection? TextDecorations => null; /// - /// Gets the text wrapping. + /// Gets a value that controls whether text wraps when it reaches the flow edge of its containing block box. /// public abstract TextWrapping TextWrapping { get; } /// - /// Line indentation + /// Gets the amount of line indentation. /// public abstract double Indent { get; } /// - /// Get the paragraph indentation. + /// Gets the paragraph indentation. /// public virtual double ParagraphIndent { @@ -75,7 +80,7 @@ public virtual double DefaultIncrementalTab => 0; /// - /// Gets the letter spacing. + /// Gets the amount of letter spacing. /// public virtual double LetterSpacing { get; } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 39bf60a42c..be4c35b353 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -374,7 +374,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.WrapWithOverflow); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow); var textSource = new SingleBufferTextSource("ABCDEFHFFHFJHKHFK", defaultProperties, true); @@ -488,7 +488,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = formatter.FormatLine(textSource, currentPosition, paragraphWidth, - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap)); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap)); Assert.NotNull(textLine); @@ -544,7 +544,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); @@ -574,7 +574,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting const string text = "012345"; var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); @@ -632,7 +632,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = formatter.FormatLine(textSource, currentPosition, 300, - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.WrapWithOverflow)); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow)); Assert.NotNull(textLine); @@ -712,7 +712,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); @@ -742,7 +742,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource("0123456789_0123456789_0123456789_0123456789", defaultProperties); var formatter = new TextFormatterImpl(); @@ -773,7 +773,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.NoWrap); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.NoWrap); var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); @@ -878,7 +878,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrapping: TextWrapping.Wrap); var text = "Hello World"; @@ -975,7 +975,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, - textWrap: wrapping); + textWrapping: wrapping); using (Start()) { @@ -993,7 +993,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, - textWrap: wrapping); + textWrapping: wrapping); using (Start()) { @@ -1077,7 +1077,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrapping: TextWrapping.Wrap); var text = "一二三四 TEXT 一二三四五六七八九十零"; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index e78f7ba1b9..5e1f6ea017 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -1168,7 +1168,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default, 72, foregroundBrush: Brushes.Black); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textLayout = new TextLayout(new SingleBufferTextSource("01", defaultProperties, true), paragraphProperties, maxWidth: 36); From a63dc1854f42cc9facd1637fd93118cc378bfaf8 Mon Sep 17 00:00:00 2001 From: davisx2 Date: Wed, 13 Aug 2025 16:12:07 +0200 Subject: [PATCH 013/154] Add Window IsDialog Property (#19226) Co-authored-by: Sebastian Redinger Co-authored-by: Max Katz --- src/Avalonia.Controls/Window.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 1f5d12c536..ee9671edda 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -481,6 +481,11 @@ namespace Avalonia.Controls } } + /// + /// Gets whether this window was opened as a dialog + /// + public bool IsDialog => _showingAsDialog; + /// /// Starts moving a window with left button being held. Should be called from left mouse button press event handler /// @@ -761,12 +766,12 @@ namespace Avalonia.Controls return null; } + _showingAsDialog = modal; RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); EnsureInitialized(); ApplyStyling(); _shown = true; - _showingAsDialog = modal; IsVisible = true; // If window position was not set before then platform may provide incorrect scaling at this time, @@ -984,7 +989,7 @@ namespace Avalonia.Controls { return; } - + var location = GetEffectiveWindowStartupLocation(owner); switch (location) @@ -1017,7 +1022,7 @@ namespace Avalonia.Controls return startupLocation; } - + private void SetWindowStartupLocation(Window? owner = null) { if (_wasShownBefore) @@ -1052,7 +1057,7 @@ namespace Avalonia.Controls screen ??= Screens.ScreenFromPoint(Position); screen ??= Screens.Primary; - + if (screen is not null) { var childRect = screen.WorkingArea.CenterRect(rect); @@ -1072,7 +1077,7 @@ namespace Avalonia.Controls var childRect = ownerRect.CenterRect(rect); var screen = Screens.ScreenFromWindow(owner); - + childRect = ApplyScreenConstraint(screen, childRect); Position = childRect.Position; @@ -1080,7 +1085,7 @@ namespace Avalonia.Controls if (!_positionWasSet && DesktopScaling != PlatformImpl?.DesktopScaling) // Platform returns incorrect scaling, forcing setting position may fix it PlatformImpl?.Move(Position); - + PixelRect ApplyScreenConstraint(Screen? screen, PixelRect childRect) { if (screen?.WorkingArea is { } constraint) From fca45c1c1638fc9801bd39da957a4da95b54d27a Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 13 Aug 2025 13:22:14 -0700 Subject: [PATCH 014/154] Migrate AvaloniaNameSourceGenerator to IIncrementalGenerator (#19216) * Add AnalyzerProject.targets targets * PrivateAssets on Workspaces.Common * Migrate AvaloniaNameSourceGenerator to IIncrementalGenerator * Remove outdated lins in the generator readme * Add GlobPattern.ToString * Fix tests * Formatting * Redo pipeline, make steps more independent from each other, compilation should be reused between dependency changes * Split XAML parsing and type resolution into separated steps * Restore CompilationReferencesComparer usage * Revert "Restore CompilationReferencesComparer usage" This reverts commit c51341990b8030bb2594ee66e8819b903383bcc1. * Split ResolvedNamesProvider pipeline step, process files in parallel * Add comment * Switch back to EquatableList * Add cancellation to the incremenetal source gen * Rethrow cancellation exception --- Avalonia.sln | 1 + build/AnalyzerProject.targets | 14 ++ .../Controls/SignUpView.xaml | 2 +- .../Avalonia.Analyzers.csproj | 13 +- .../Avalonia.Generators.csproj | 8 +- .../Common/Domain/ICodeGenerator.cs | 3 +- .../Common/Domain/IGlobPattern.cs | 4 +- .../Common/Domain/INameResolver.cs | 12 +- .../Common/Domain/IViewResolver.cs | 41 +++- .../Common/EquatableList.cs | 58 +++++ .../Avalonia.Generators/Common/GlobPattern.cs | 7 + .../Common/GlobPatternGroup.cs | 22 +- .../Common/ResolverExtensions.cs | 20 +- .../Common/XamlXNameResolver.cs | 65 +++--- .../Common/XamlXViewResolver.cs | 75 ++----- .../Compiler/MiniCompiler.cs | 64 +++++- .../Compiler/NoopTypeSystem.cs | 22 ++ .../Compiler/RoslynTypeSystem.cs | 8 +- .../GeneratorContextExtensions.cs | 44 ---- .../GeneratorExtensions.cs | 43 ++++ .../Avalonia.Generators/GeneratorOptions.cs | 83 ++++--- .../NameGenerator/AvaloniaNameGenerator.cs | 66 ------ .../AvaloniaNameIncrementalGenerator.cs | 211 ++++++++++++++++++ .../AvaloniaNameSourceGenerator.cs | 86 ------- .../NameGenerator/INameGenerator.cs | 12 +- .../InitializeComponentCodeGenerator.cs | 29 +-- .../OnlyPropertiesCodeGenerator.cs | 6 +- .../NameGenerator/TrackingNames.cs | 10 + src/tools/Avalonia.Generators/README.md | 21 +- src/tools/DevAnalyzers/DevAnalyzers.csproj | 10 +- src/tools/DevGenerators/DevGenerators.csproj | 8 +- .../Avalonia.Generators.Tests.csproj | 2 +- .../CompilationUtils.cs | 24 ++ .../InitializeComponentTests.cs | 30 +-- .../MiniCompilerTests.cs | 4 +- .../OnlyProperties/OnlyPropertiesTests.cs | 27 ++- .../XamlXClassResolverTests.cs | 13 +- .../XamlXNameResolverTests.cs | 24 +- 38 files changed, 708 insertions(+), 484 deletions(-) create mode 100644 build/AnalyzerProject.targets create mode 100644 src/tools/Avalonia.Generators/Common/EquatableList.cs create mode 100644 src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs delete mode 100644 src/tools/Avalonia.Generators/GeneratorContextExtensions.cs create mode 100644 src/tools/Avalonia.Generators/GeneratorExtensions.cs delete mode 100644 src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs create mode 100644 src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs delete mode 100644 src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs create mode 100644 src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs create mode 100644 tests/Avalonia.Generators.Tests/CompilationUtils.cs diff --git a/Avalonia.sln b/Avalonia.sln index 3103ddeb16..b4decb7dcc 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -121,6 +121,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Props", "Props", "{F3AC8BC1 build\UnitTests.NetFX.props = build\UnitTests.NetFX.props build\WarnAsErrors.props = build\WarnAsErrors.props build\XUnit.props = build\XUnit.props + build\AnalyzerProject.targets = build\AnalyzerProject.targets EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Targets", "Targets", "{4D6FAF79-58B4-482F-9122-0668C346364C}" diff --git a/build/AnalyzerProject.targets b/build/AnalyzerProject.targets new file mode 100644 index 0000000000..f067ec0418 --- /dev/null +++ b/build/AnalyzerProject.targets @@ -0,0 +1,14 @@ + + + + true + true + + + + + + + + + diff --git a/samples/Generators.Sandbox/Controls/SignUpView.xaml b/samples/Generators.Sandbox/Controls/SignUpView.xaml index c126f36f53..1cfb581cf9 100644 --- a/samples/Generators.Sandbox/Controls/SignUpView.xaml +++ b/samples/Generators.Sandbox/Controls/SignUpView.xaml @@ -8,7 +8,7 @@ Watermark="Please, enter user name..." UseFloatingWatermark="True" /> netstandard2.0 false - true embedded true false - true - - - - - - - - + + + diff --git a/src/tools/Avalonia.Generators/Avalonia.Generators.csproj b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj index 7945839563..8b8ac0db39 100644 --- a/src/tools/Avalonia.Generators/Avalonia.Generators.csproj +++ b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj @@ -6,17 +6,10 @@ embedded true false - true - true enable ../../../external/XamlX/src/XamlX - - - - - + diff --git a/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs b/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs index 4b426172f8..12dcbaf857 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; internal interface ICodeGenerator { - string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names); + string GenerateCode(string className, string nameSpace, IEnumerable names); } diff --git a/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs b/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs index 04dbf9cbb9..09279d6986 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs @@ -1,6 +1,8 @@ +using System; + namespace Avalonia.Generators.Common.Domain; -internal interface IGlobPattern +internal interface IGlobPattern : IEquatable { bool Matches(string str); } diff --git a/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs b/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs index cb5488d8a3..5943d73fa7 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; using XamlX.Ast; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; @@ -13,7 +15,11 @@ internal enum NamedFieldModifier internal interface INameResolver { - IReadOnlyList ResolveNames(XamlDocument xaml); + EquatableList ResolveXmlNames(XamlDocument xaml, CancellationToken cancellationToken); + ResolvedName ResolveName(IXamlType xamlType, string name, string? fieldModifier); } -internal record ResolvedName(string TypeName, string Name, string FieldModifier); +internal record XamlXmlType(string Name, string? XmlNamespace, EquatableList GenericArguments); + +internal record ResolvedXmlName(XamlXmlType XmlType, string Name, string? FieldModifier); +internal record ResolvedName(string TypeName, string Name, string? FieldModifier); diff --git a/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs b/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs index 49ceb6f69e..689aa25970 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs @@ -1,11 +1,46 @@ +using System.Collections.Immutable; +using System.Threading; using XamlX.Ast; -using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; internal interface IViewResolver { - ResolvedView? ResolveView(string xaml); + ResolvedViewDocument? ResolveView(string xaml, CancellationToken cancellationToken); } -internal record ResolvedView(string ClassName, IXamlType XamlType, string Namespace, XamlDocument Xaml); +internal record ResolvedViewInfo(string ClassName, string Namespace) +{ + public string FullName => $"{Namespace}.{ClassName}"; + public override string ToString() => FullName; +} + +internal record ResolvedViewDocument(string ClassName, string Namespace, XamlDocument Xaml) + : ResolvedViewInfo(ClassName, Namespace); + +internal record ResolvedXmlView( + string ClassName, + string Namespace, + EquatableList XmlNames) + : ResolvedViewInfo(ClassName, Namespace) +{ + public ResolvedXmlView(ResolvedViewInfo info, EquatableList xmlNames) + : this(info.ClassName, info.Namespace, xmlNames) + { + + } +} + +internal record ResolvedView( + string ClassName, + string Namespace, + bool IsWindow, + EquatableList Names) + : ResolvedViewInfo(ClassName, Namespace) +{ + public ResolvedView(ResolvedViewInfo info, bool isWindow, EquatableList names) + : this(info.ClassName, info.Namespace, isWindow, names) + { + + } +} diff --git a/src/tools/Avalonia.Generators/Common/EquatableList.cs b/src/tools/Avalonia.Generators/Common/EquatableList.cs new file mode 100644 index 0000000000..2b4c8a184d --- /dev/null +++ b/src/tools/Avalonia.Generators/Common/EquatableList.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Avalonia.Generators.Common; + +// https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md#pipeline-model-design +// With minor modification to use ReadOnlyCollection instead of List +internal class EquatableList(IList collection) + : ReadOnlyCollection(collection), IEquatable> +{ + public bool Equals(EquatableList? other) + { + // If the other list is null or a different size, they're not equal + if (other is null || Count != other.Count) + { + return false; + } + + // Compare each pair of elements for equality + for (int i = 0; i < Count; i++) + { + if (!EqualityComparer.Default.Equals(this[i], other[i])) + { + return false; + } + } + + // If we got this far, the lists are equal + return true; + } + + public override bool Equals(object? obj) + { + return Equals(obj as EquatableList); + } + + public override int GetHashCode() + { + var hash = 0; + for (var i = 0; i < Count; i++) + { + hash ^= this[i]?.GetHashCode() ?? 0; + } + return hash; + } + + public static bool operator ==(EquatableList? list1, EquatableList? list2) + { + return ReferenceEquals(list1, list2) + || list1 is not null && list2 is not null && list1.Equals(list2); + } + + public static bool operator !=(EquatableList? list1, EquatableList? list2) + { + return !(list1 == list2); + } +} diff --git a/src/tools/Avalonia.Generators/Common/GlobPattern.cs b/src/tools/Avalonia.Generators/Common/GlobPattern.cs index 484e17d787..b76f4b2566 100644 --- a/src/tools/Avalonia.Generators/Common/GlobPattern.cs +++ b/src/tools/Avalonia.Generators/Common/GlobPattern.cs @@ -7,12 +7,19 @@ internal class GlobPattern : IGlobPattern { private const RegexOptions GlobOptions = RegexOptions.IgnoreCase | RegexOptions.Singleline; private readonly Regex _regex; + private readonly string _pattern; public GlobPattern(string pattern) { + _pattern = pattern; var expression = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$"; _regex = new Regex(expression, GlobOptions); } public bool Matches(string str) => _regex.IsMatch(str); + + public bool Equals(IGlobPattern other) => other is GlobPattern pattern && pattern._pattern == _pattern; + public override int GetHashCode() => _pattern.GetHashCode(); + public override bool Equals(object? obj) => obj is GlobPattern pattern && Equals(pattern); + public override string ToString() => _pattern; } diff --git a/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs b/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs index 1358ee7920..f32f7c9a02 100644 --- a/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs +++ b/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs @@ -4,14 +4,20 @@ using Avalonia.Generators.Common.Domain; namespace Avalonia.Generators.Common; -internal class GlobPatternGroup : IGlobPattern +internal class GlobPatternGroup(IEnumerable patterns) + : EquatableList(patterns.Select(p => new GlobPattern(p)).ToArray()), IGlobPattern { - private readonly GlobPattern[] _patterns; + public bool Matches(string str) + { + for (var i = 0; i < Count; i++) + { + if (this[i].Matches(str)) + return true; + } + return false; + } - public GlobPatternGroup(IEnumerable patterns) => - _patterns = patterns - .Select(pattern => new GlobPattern(pattern)) - .ToArray(); - - public bool Matches(string str) => _patterns.Any(pattern => pattern.Matches(str)); + public bool Equals(IGlobPattern other) => other is GlobPatternGroup group && base.Equals(group); + public override string ToString() => $"[{string.Join(", ", this.Select(p => p.ToString()))}]"; } + diff --git a/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs b/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs index 04352298c8..092eee6e2e 100644 --- a/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs +++ b/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using XamlX.TypeSystem; namespace Avalonia.Generators.Common; @@ -6,20 +6,14 @@ namespace Avalonia.Generators.Common; internal static class ResolverExtensions { public static bool IsAvaloniaStyledElement(this IXamlType clrType) => - clrType.HasStyledElementBaseType() || - clrType.HasIStyledElementInterface(); + Inherits(clrType, "Avalonia.StyledElement"); + public static bool IsAvaloniaWindow(this IXamlType clrType) => + Inherits(clrType, "Avalonia.Controls.Window"); - private static bool HasStyledElementBaseType(this IXamlType clrType) + private static bool Inherits(IXamlType clrType, string metadataName) { - // Check for the base type since IStyledElement interface is removed. - // https://github.com/AvaloniaUI/Avalonia/pull/9553 - if (clrType.FullName == "Avalonia.StyledElement") + if (string.Equals(clrType.FullName, metadataName, StringComparison.Ordinal)) return true; - return clrType.BaseType != null && IsAvaloniaStyledElement(clrType.BaseType); + return clrType.BaseType is { } baseType && Inherits(baseType, metadataName); } - - private static bool HasIStyledElementInterface(this IXamlType clrType) => - clrType.Interfaces.Any(abstraction => - abstraction.IsInterface && - abstraction.FullName == "Avalonia.IStyledElement"); } diff --git a/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs b/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs index 955df90ddd..0081d76196 100644 --- a/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs +++ b/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs @@ -1,38 +1,56 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; using Avalonia.Generators.Common.Domain; using XamlX; using XamlX.Ast; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common; -internal class XamlXNameResolver : INameResolver, IXamlAstVisitor +internal class XamlXNameResolver + : INameResolver, IXamlAstVisitor { - private readonly List _items = new(); - private readonly string _defaultFieldModifier; + private readonly Dictionary _items = new(); + private CancellationToken _cancellationToken; - public XamlXNameResolver(NamedFieldModifier namedFieldModifier = NamedFieldModifier.Internal) + public EquatableList ResolveXmlNames(XamlDocument xaml, CancellationToken cancellationToken) { - _defaultFieldModifier = namedFieldModifier.ToString().ToLowerInvariant(); + _items.Clear(); + try + { + _cancellationToken = cancellationToken; + xaml.Root.Visit(this); + xaml.Root.VisitChildren(this); + } + finally + { + _cancellationToken = CancellationToken.None; + } + + return new EquatableList(_items.Values.ToArray()); } - public IReadOnlyList ResolveNames(XamlDocument xaml) + public ResolvedName ResolveName(IXamlType clrType, string name, string? fieldModifier) { - _items.Clear(); - xaml.Root.Visit(this); - xaml.Root.VisitChildren(this); - return _items; + var typeName = $"{clrType.Namespace}.{clrType.Name}"; + var typeAgs = clrType.GenericArguments.Select(arg => arg.FullName).ToImmutableList(); + var genericTypeName = typeAgs.Count == 0 + ? $"global::{typeName}" + : $"global::{typeName}<{string.Join(", ", typeAgs.Select(arg => $"global::{arg}"))}>"; + return new ResolvedName(genericTypeName, name, fieldModifier); } IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) { + _cancellationToken.ThrowIfCancellationRequested(); + if (node is not XamlAstObjectNode objectNode) return node; - var clrType = objectNode.Type.GetClrType(); - if (!clrType.IsAvaloniaStyledElement()) - return node; + var xamlType = (XamlAstXmlTypeReference)objectNode.Type; foreach (var child in objectNode.Children) { @@ -44,27 +62,24 @@ internal class XamlXNameResolver : INameResolver, IXamlAstVisitor propertyValueNode.Values[0] is XamlAstTextNode text) { var fieldModifier = TryGetFieldModifier(objectNode); - var typeName = $@"{clrType.Namespace}.{clrType.Name}"; - var typeAgs = clrType.GenericArguments.Select(arg => arg.FullName).ToImmutableList(); - var genericTypeName = typeAgs.Count == 0 - ? $"global::{typeName}" - : $@"global::{typeName}<{string.Join(", ", typeAgs.Select(arg => $"global::{arg}"))}>"; - - var resolvedName = new ResolvedName(genericTypeName, text.Text, fieldModifier); - if (_items.Contains(resolvedName)) + var resolvedName = new ResolvedXmlName(ConvertType(xamlType), text.Text, fieldModifier); + if (_items.ContainsKey(text.Text)) continue; - _items.Add(resolvedName); + _items.Add(text.Text, resolvedName); } } return node; + + static XamlXmlType ConvertType(XamlAstXmlTypeReference type) => new(type.Name, type.XmlNamespace, + new EquatableList(type.GenericArguments.Select(ConvertType).ToArray())); } void IXamlAstVisitor.Push(IXamlAstNode node) { } void IXamlAstVisitor.Pop() { } - private string TryGetFieldModifier(XamlAstObjectNode objectNode) + private string? TryGetFieldModifier(XamlAstObjectNode objectNode) { // We follow Xamarin.Forms API behavior in terms of x:FieldModifier here: // https://docs.microsoft.com/en-us/xamarin/xamarin-forms/xaml/field-modifiers @@ -87,7 +102,7 @@ internal class XamlXNameResolver : INameResolver, IXamlAstVisitor "protected" => "protected", "internal" => "internal", "notpublic" => "internal", - _ => _defaultFieldModifier + _ => null }; } diff --git a/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs b/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs index b0495b2840..35880dcc44 100644 --- a/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs +++ b/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs @@ -1,92 +1,61 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using Avalonia.Generators.Common.Domain; using Avalonia.Generators.Compiler; using XamlX; using XamlX.Ast; using XamlX.Parsers; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common; -internal class XamlXViewResolver : IViewResolver, IXamlAstVisitor +internal class XamlXViewResolver(MiniCompiler compiler) : IViewResolver, IXamlAstVisitor { - private readonly RoslynTypeSystem _typeSystem; - private readonly MiniCompiler _compiler; - private readonly bool _checkTypeValidity; - private readonly Action? _onTypeInvalid; - private readonly Action? _onUnhandledError; - - private ResolvedView? _resolvedClass; + private ResolvedViewDocument? _resolvedClass; private XamlDocument? _xaml; + private CancellationToken _cancellationToken; - public XamlXViewResolver( - RoslynTypeSystem typeSystem, - MiniCompiler compiler, - bool checkTypeValidity = false, - Action? onTypeInvalid = null, - Action? onUnhandledError = null) + public ResolvedViewDocument? ResolveView(string xaml, CancellationToken cancellationToken) { - _checkTypeValidity = checkTypeValidity; - _onTypeInvalid = onTypeInvalid; - _onUnhandledError = onUnhandledError; - _typeSystem = typeSystem; - _compiler = compiler; - } + _resolvedClass = null; + _xaml = XDocumentXamlParser.Parse(xaml, new Dictionary + { + {XamlNamespaces.Blend2008, XamlNamespaces.Blend2008} + }); - public ResolvedView? ResolveView(string xaml) - { try { - _resolvedClass = null; - _xaml = XDocumentXamlParser.Parse(xaml, new Dictionary - { - {XamlNamespaces.Blend2008, XamlNamespaces.Blend2008} - }); - - _compiler.Transform(_xaml); + _cancellationToken = cancellationToken; + compiler.TransformWithCancellation(_xaml, cancellationToken); _xaml.Root.Visit(this); _xaml.Root.VisitChildren(this); - return _resolvedClass; } - catch (Exception exception) + finally { - _onUnhandledError?.Invoke(exception); - return null; + _cancellationToken = CancellationToken.None; } + return _resolvedClass; } - + IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) { - if (node is not XamlAstObjectNode objectNode) - return node; + _cancellationToken.ThrowIfCancellationRequested(); - var clrType = objectNode.Type.GetClrType(); - if (!clrType.IsAvaloniaStyledElement()) + if (node is not XamlAstObjectNode objectNode) return node; foreach (var child in objectNode.Children) { - if (child is XamlAstXmlDirective directive && - directive.Name == "Class" && - directive.Namespace == XamlNamespaces.Xaml2006 && - directive.Values[0] is XamlAstTextNode text) + if (child is XamlAstXmlDirective { Name: "Class", Namespace: XamlNamespaces.Xaml2006 } directive + && directive.Values[0] is XamlAstTextNode text) { - if (_checkTypeValidity) - { - var existingType = _typeSystem.FindType(text.Text); - if (existingType == null) - { - _onTypeInvalid?.Invoke(text.Text); - return node; - } - } - var split = text.Text.Split('.'); var nameSpace = string.Join(".", split.Take(split.Length - 1)); var className = split.Last(); - _resolvedClass = new ResolvedView(className, clrType, nameSpace, _xaml!); + _resolvedClass = new ResolvedViewDocument(className, nameSpace, _xaml!); return node; } } diff --git a/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs index b0421cd245..0c7805bb38 100644 --- a/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs +++ b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs @@ -1,6 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; +using Avalonia.Generators.Common.Domain; +using XamlX.Ast; using XamlX.Compiler; using XamlX.Emit; using XamlX.Transform; @@ -14,7 +17,22 @@ internal sealed class MiniCompiler : XamlCompiler public const string AvaloniaXmlnsDefinitionAttribute = "Avalonia.Metadata.XmlnsDefinitionAttribute"; [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = TrimmingMessages.Roslyn)] - public static MiniCompiler CreateDefault(RoslynTypeSystem typeSystem, params string[] additionalTypes) + public static MiniCompiler CreateNoop() + { + var typeSystem = new NoopTypeSystem(); + var mappings = new XamlLanguageTypeMappings(typeSystem); + var diagnosticsHandler = new XamlDiagnosticsHandler(); + + var configuration = new TransformerConfiguration( + typeSystem, + typeSystem.Assemblies.First(), + mappings, + diagnosticsHandler: diagnosticsHandler); + return new MiniCompiler(configuration); + } + + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = TrimmingMessages.Roslyn)] + public static MiniCompiler CreateRoslyn(RoslynTypeSystem typeSystem, params string[] additionalTypes) { var mappings = new XamlLanguageTypeMappings(typeSystem); foreach (var additionalType in additionalTypes) @@ -29,7 +47,7 @@ internal sealed class MiniCompiler : XamlCompiler diagnosticsHandler: diagnosticsHandler); return new MiniCompiler(configuration); } - + private MiniCompiler(TransformerConfiguration configuration) : base(configuration, new XamlLanguageEmitMappings(), false) { @@ -38,9 +56,42 @@ internal sealed class MiniCompiler : XamlCompiler Transformers.Add(new KnownDirectivesTransformer()); Transformers.Add(new XamlIntrinsicsTransformer()); Transformers.Add(new XArgumentsTransformer()); - Transformers.Add(new TypeReferenceResolver()); } + public IXamlTypeSystem TypeSystem => _configuration.TypeSystem; + + public void TransformWithCancellation(XamlDocument doc, CancellationToken cancellationToken) + { + var ctx = CreateTransformationContext(doc); + + var root = doc.Root; + ctx.RootObject = new XamlRootObjectNode((XamlAstObjectNode)root); + foreach (var transformer in Transformers) + { + cancellationToken.ThrowIfCancellationRequested(); + ctx.VisitChildren(ctx.RootObject, transformer); + root = ctx.Visit(root, transformer); + } + + foreach (var simplifier in SimplificationTransformers) + { + cancellationToken.ThrowIfCancellationRequested(); + root = ctx.Visit(root, simplifier); + } + + doc.Root = root; + } + + public IXamlType ResolveXamlType(XamlXmlType type) + { + var clrTypeRef = TypeReferenceResolver.ResolveType( + new AstTransformationContext(_configuration, null), ToTypeRef(type)); + return clrTypeRef.Type; + + static XamlAstXmlTypeReference ToTypeRef(XamlXmlType type) => new(EmptyLineInfo.Instance, + type.XmlNamespace, type.Name, type.GenericArguments.Select(ToTypeRef)); + } + protected override XamlEmitContext InitCodeGen( IFileSource file, IXamlTypeBuilder declaringType, @@ -48,4 +99,11 @@ internal sealed class MiniCompiler : XamlCompiler XamlRuntimeContext context, bool needContextLocal) => throw new NotSupportedException(); + + private class EmptyLineInfo : IXamlLineInfo + { + public static IXamlLineInfo Instance { get; } = new EmptyLineInfo(); + public int Line { get => 0; set { } } + public int Position { get => 0; set { } } + } } diff --git a/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs b/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs new file mode 100644 index 0000000000..b9bc801a52 --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Compiler; + +internal class NoopTypeSystem : IXamlTypeSystem +{ + public IEnumerable Assemblies => [NoopAssembly.Instance]; + public IXamlAssembly? FindAssembly(string substring) => null; + public IXamlType? FindType(string name) => XamlPseudoType.Unresolved(name); + public IXamlType? FindType(string name, string assembly) => XamlPseudoType.Unresolved(name); + + internal class NoopAssembly : IXamlAssembly + { + public static NoopAssembly Instance { get; } = new(); + public bool Equals(IXamlAssembly other) => ReferenceEquals(this, other); + public string Name { get; } = "Noop"; + public IReadOnlyList CustomAttributes { get; } = []; + public IXamlType? FindType(string fullName) => XamlPseudoType.Unresolved(fullName); + } +} + diff --git a/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs index 7e0ab123f4..04e0e594c4 100644 --- a/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs +++ b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -11,8 +12,9 @@ namespace Avalonia.Generators.Compiler; internal class RoslynTypeSystem : IXamlTypeSystem { private readonly List _assemblies = new(); + private readonly ConcurrentDictionary _typeCache = new(); - public RoslynTypeSystem(CSharpCompilation compilation) + public RoslynTypeSystem(Compilation compilation) { _assemblies.Add(new RoslynAssembly(compilation.Assembly)); @@ -34,9 +36,9 @@ internal class RoslynTypeSystem : IXamlTypeSystem [UnconditionalSuppressMessage("Trimming", "IL2092", Justification = TrimmingMessages.Roslyn)] public IXamlType? FindType(string name) => - _assemblies + _typeCache.GetOrAdd(name, _ => _assemblies .Select(assembly => assembly.FindType(name)) - .FirstOrDefault(type => type != null); + .FirstOrDefault(type => type != null)); [UnconditionalSuppressMessage("Trimming", "IL2092", Justification = TrimmingMessages.Roslyn)] public IXamlType? FindType(string name, string assembly) => diff --git a/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs b/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs deleted file mode 100644 index b1f7738a8a..0000000000 --- a/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using Microsoft.CodeAnalysis; - -namespace Avalonia.Generators; - -internal static class GeneratorContextExtensions -{ - private const string UnhandledErrorDescriptorId = "AXN0002"; - private const string InvalidTypeDescriptorId = "AXN0001"; - - public static string GetMsBuildProperty( - this GeneratorExecutionContext context, - string name, - string defaultValue = "") - { - context.AnalyzerConfigOptions.GlobalOptions.TryGetValue($"build_property.{name}", out var value); - return value ?? defaultValue; - } - - public static void ReportNameGeneratorUnhandledError(this GeneratorExecutionContext context, Exception error) => - context.Report(UnhandledErrorDescriptorId, - "Unhandled exception occurred while generating typed Name references. " + - "Please file an issue: https://github.com/avaloniaui/Avalonia", - error.Message, - error.ToString()); - - public static void ReportNameGeneratorInvalidType(this GeneratorExecutionContext context, string typeName) => - context.Report(InvalidTypeDescriptorId, - $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + - $"The type '{typeName}' does not exist in the assembly."); - - private static void Report(this GeneratorExecutionContext context, string id, string title, string? message = null, string? description = null) => - context.ReportDiagnostic( - Diagnostic.Create( - new DiagnosticDescriptor( - id: id, - title: title, - messageFormat: message ?? title, - category: "Usage", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true, - description), - Location.None)); -} diff --git a/src/tools/Avalonia.Generators/GeneratorExtensions.cs b/src/tools/Avalonia.Generators/GeneratorExtensions.cs new file mode 100644 index 0000000000..9553dddc46 --- /dev/null +++ b/src/tools/Avalonia.Generators/GeneratorExtensions.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Avalonia.Generators; + +internal static class GeneratorExtensions +{ + private const string UnhandledErrorDescriptorId = "AXN0002"; + private const string InvalidTypeDescriptorId = "AXN0001"; + + public static string GetMsBuildProperty( + this AnalyzerConfigOptions options, + string name, + string defaultValue = "") + { + options.TryGetValue($"build_property.{name}", out var value); + return value ?? defaultValue; + } + + public static DiagnosticDescriptor NameGeneratorUnhandledError(Exception error) => new( + UnhandledErrorDescriptorId, + title: "Unhandled exception occurred while generating typed Name references. " + + "Please file an issue: https://github.com/avaloniaui/Avalonia", + messageFormat: error.Message, + description: error.ToString(), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static DiagnosticDescriptor NameGeneratorInvalidType(string typeName) => new( + InvalidTypeDescriptorId, + title: $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + + $"The type '{typeName}' does not exist in the assembly.", + messageFormat: $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + + $"The type '{typeName}' does not exist in the assembly.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static void Report(this SourceProductionContext context, DiagnosticDescriptor diagnostics) => + context.ReportDiagnostic(Diagnostic.Create(diagnostics, Location.None)); +} diff --git a/src/tools/Avalonia.Generators/GeneratorOptions.cs b/src/tools/Avalonia.Generators/GeneratorOptions.cs index 9dcf5062f4..b9066aa3ca 100644 --- a/src/tools/Avalonia.Generators/GeneratorOptions.cs +++ b/src/tools/Avalonia.Generators/GeneratorOptions.cs @@ -1,7 +1,8 @@ using System; +using Avalonia.Generators.Common; using Avalonia.Generators.Common.Domain; using Avalonia.Generators.NameGenerator; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; namespace Avalonia.Generators; @@ -18,58 +19,72 @@ internal enum BuildProperties // TODO add other generators properties here. } -internal class GeneratorOptions +internal record GeneratorOptions { - private readonly GeneratorExecutionContext _context; - - public GeneratorOptions(GeneratorExecutionContext context) => _context = context; + public GeneratorOptions(AnalyzerConfigOptions options) + { + AvaloniaNameGeneratorIsEnabled = GetBoolProperty( + options, + BuildProperties.AvaloniaNameGeneratorIsEnabled, + true); + AvaloniaNameGeneratorBehavior = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorBehavior, + Behavior.InitializeComponent); + AvaloniaNameGeneratorClassFieldModifier = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorDefaultFieldModifier, + NamedFieldModifier.Internal); + AvaloniaNameGeneratorViewFileNamingStrategy = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorViewFileNamingStrategy, + ViewFileNamingStrategy.NamespaceAndClassName); + AvaloniaNameGeneratorFilterByPath = new GlobPatternGroup(GetStringArrayProperty( + options, + BuildProperties.AvaloniaNameGeneratorFilterByPath, + "*")); + AvaloniaNameGeneratorFilterByNamespace = new GlobPatternGroup(GetStringArrayProperty( + options, + BuildProperties.AvaloniaNameGeneratorFilterByNamespace, + "*")); + AvaloniaNameGeneratorAttachDevTools = GetBoolProperty( + options, + BuildProperties.AvaloniaNameGeneratorAttachDevTools, + true); + } - public bool AvaloniaNameGeneratorIsEnabled => GetBoolProperty( - BuildProperties.AvaloniaNameGeneratorIsEnabled, - true); + public bool AvaloniaNameGeneratorIsEnabled { get; } - public Behavior AvaloniaNameGeneratorBehavior => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorBehavior, - Behavior.InitializeComponent); + public Behavior AvaloniaNameGeneratorBehavior { get; } - public NamedFieldModifier AvaloniaNameGeneratorClassFieldModifier => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorDefaultFieldModifier, - NamedFieldModifier.Internal); + public NamedFieldModifier AvaloniaNameGeneratorClassFieldModifier { get; } - public ViewFileNamingStrategy AvaloniaNameGeneratorViewFileNamingStrategy => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorViewFileNamingStrategy, - ViewFileNamingStrategy.NamespaceAndClassName); + public ViewFileNamingStrategy AvaloniaNameGeneratorViewFileNamingStrategy { get; } - public string[] AvaloniaNameGeneratorFilterByPath => GetStringArrayProperty( - BuildProperties.AvaloniaNameGeneratorFilterByPath, - "*"); + public IGlobPattern AvaloniaNameGeneratorFilterByPath { get; } - public string[] AvaloniaNameGeneratorFilterByNamespace => GetStringArrayProperty( - BuildProperties.AvaloniaNameGeneratorFilterByNamespace, - "*"); + public IGlobPattern AvaloniaNameGeneratorFilterByNamespace { get; } - public bool AvaloniaNameGeneratorAttachDevTools => GetBoolProperty( - BuildProperties.AvaloniaNameGeneratorAttachDevTools, - true); + public bool AvaloniaNameGeneratorAttachDevTools { get; } - private string[] GetStringArrayProperty(BuildProperties name, string defaultValue) + private static string[] GetStringArrayProperty(AnalyzerConfigOptions options, BuildProperties name, string defaultValue) { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue); - return value.Contains(";") ? value.Split(';') : new[] {value}; + var value = options.GetMsBuildProperty(key, defaultValue); + return value.Contains(";") ? value.Split(';') : [value]; } - private TEnum GetEnumProperty(BuildProperties name, TEnum defaultValue) where TEnum : struct + private static TEnum GetEnumProperty(AnalyzerConfigOptions options, BuildProperties name, TEnum defaultValue) where TEnum : struct { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue.ToString()); + var value = options.GetMsBuildProperty(key, defaultValue.ToString()); return Enum.TryParse(value, true, out TEnum behavior) ? behavior : defaultValue; } - - private bool GetBoolProperty(BuildProperties name, bool defaultValue) + + private static bool GetBoolProperty(AnalyzerConfigOptions options, BuildProperties name, bool defaultValue) { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue.ToString()); + var value = options.GetMsBuildProperty(key, defaultValue.ToString()); return bool.TryParse(value, out var result) ? result : defaultValue; } } diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs deleted file mode 100644 index 67389ef826..0000000000 --- a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Avalonia.Generators.Common.Domain; -using Microsoft.CodeAnalysis; - -namespace Avalonia.Generators.NameGenerator; - -internal class AvaloniaNameGenerator : INameGenerator -{ - private readonly ViewFileNamingStrategy _naming; - private readonly IGlobPattern _pathPattern; - private readonly IGlobPattern _namespacePattern; - private readonly IViewResolver _classes; - private readonly INameResolver _names; - private readonly ICodeGenerator _code; - - public AvaloniaNameGenerator( - ViewFileNamingStrategy naming, - IGlobPattern pathPattern, - IGlobPattern namespacePattern, - IViewResolver classes, - INameResolver names, - ICodeGenerator code) - { - _naming = naming; - _pathPattern = pathPattern; - _namespacePattern = namespacePattern; - _classes = classes; - _names = names; - _code = code; - } - - public IEnumerable GenerateNameReferences(IEnumerable additionalFiles, CancellationToken cancellationToken) - { - var resolveViews = - from file in additionalFiles - let filePath = file.Path - where (filePath.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase) || - filePath.EndsWith(".paml", StringComparison.OrdinalIgnoreCase) || - filePath.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase)) && - _pathPattern.Matches(filePath) - let xaml = file.GetText(cancellationToken)?.ToString() - where xaml != null - let view = _classes.ResolveView(xaml) - where view != null && _namespacePattern.Matches(view.Namespace) - select view; - - var query = - from view in resolveViews - let names = _names.ResolveNames(view.Xaml) - let code = _code.GenerateCode(view.ClassName, view.Namespace, view.XamlType, names) - let fileName = ResolveViewFileName(view, _naming) - select new GeneratedPartialClass(fileName, code); - - return query; - } - - private static string ResolveViewFileName(ResolvedView view, ViewFileNamingStrategy strategy) => strategy switch - { - ViewFileNamingStrategy.ClassName => $"{view.ClassName}.g.cs", - ViewFileNamingStrategy.NamespaceAndClassName => $"{view.Namespace}.{view.ClassName}.g.cs", - _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown naming strategy!") - }; -} diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs new file mode 100644 index 0000000000..ba0d0d7579 --- /dev/null +++ b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; +using Avalonia.Generators.Compiler; +using Microsoft.CodeAnalysis; +using XamlX.Transform; + +namespace Avalonia.Generators.NameGenerator; + +[Generator(LanguageNames.CSharp)] +public class AvaloniaNameIncrementalGenerator : IIncrementalGenerator +{ + private const string SourceItemGroupMetadata = "build_metadata.AdditionalFiles.SourceItemGroup"; + private static readonly MiniCompiler s_noopCompiler = MiniCompiler.CreateNoop(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Map MSBuild properties onto readonly GeneratorOptions. + var options = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => new GeneratorOptions(options.GlobalOptions)) + .WithTrackingName(TrackingNames.XamlGeneratorOptionsProvider); + + // Filter additional texts, we only need Avalonia XAML files. + var xamlFiles = context.AdditionalTextsProvider + .Combine(options.Combine(context.AnalyzerConfigOptionsProvider)) + .Where(static pair => + { + var text = pair.Left; + var (options, optionsProvider) = pair.Right; + var filePath = text.Path; + + if (!(filePath.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".paml", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + if (!options.AvaloniaNameGeneratorFilterByPath.Matches(filePath)) + { + return false; + } + + if (!optionsProvider.GetOptions(pair.Left).TryGetValue(SourceItemGroupMetadata, out var itemGroup) + || itemGroup != "AvaloniaXaml") + { + return false; + } + + return true; + }) + .Select(static (pair, _) => pair.Left) + .WithTrackingName(TrackingNames.InputXamlFilesProvider); + + // Actual parsing step. We input XAML files one by one, but don't resolve any types. + // That's why we use NoOp type system here, allowing parsing to run detached from C# compilation. + // Otherwise we would need to re-parse XAML on any C# file changed. + var parsedXamlClasses = xamlFiles + .Select(static (file, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + var text = file.GetText(cancellationToken); + var diagnostics = new List(); + if (text is not null) + { + try + { + var xaml = text.ToString(); + var viewResolver = new XamlXViewResolver(s_noopCompiler); + var view = viewResolver.ResolveView(xaml, cancellationToken); + if (view is null) + { + return null; + } + + var nameResolver = new XamlXNameResolver(); + var xmlNames = nameResolver.ResolveXmlNames(view.Xaml, cancellationToken); + + return new XmlClassInfo( + new ResolvedXmlView(view, xmlNames), + new EquatableList(diagnostics)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorUnhandledError(ex)); + return new XmlClassInfo(null, new EquatableList(diagnostics)); + } + } + + return null; + }) + .Where(request => request is not null) + .WithTrackingName(TrackingNames.ParsedXamlClasses); + + // IMPORTANT: we shouldn't cache CompilationProvider as a whole, + // But we also should keep in mind that CompilationProvider can frequently re-trigger generator. + var compiler = context.CompilationProvider + .Select(static (compilation, _) => + { + var roslynTypeSystem = new RoslynTypeSystem(compilation); + return MiniCompiler.CreateRoslyn(roslynTypeSystem, MiniCompiler.AvaloniaXmlnsDefinitionAttribute); + }) + .WithTrackingName(TrackingNames.XamlTypeSystem); + + // Note: this step will be re-executed on any C# file changes. + // As much as possible heavy tasks should be moved outside of this step, like XAML parsing. + var resolvedNames = parsedXamlClasses + .Combine(compiler) + .Select(static (pair, ct) => + { + var (classInfo, compiler) = pair; + var hasDevToolsReference = compiler.TypeSystem.FindAssembly("Avalonia.Diagnostics") is not null; + var nameResolver = new XamlXNameResolver(); + + var diagnostics = new List(classInfo!.Diagnostics); + ResolvedView? view = null; + if (classInfo.XmlView is { } xmlView) + { + var type = compiler.TypeSystem.FindType(xmlView.FullName); + + if (type is null) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorInvalidType(xmlView.FullName)); + } + else if (type.IsAvaloniaStyledElement()) + { + var resolvedNames = new List(); + foreach (var xmlName in xmlView.XmlNames) + { + ct.ThrowIfCancellationRequested(); + + try + { + var clrType = compiler.ResolveXamlType(xmlName.XmlType); + if (!clrType.IsAvaloniaStyledElement()) + { + continue; + } + + resolvedNames.Add(nameResolver + .ResolveName(clrType, xmlName.Name, xmlName.FieldModifier)); + } + catch (Exception ex) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorUnhandledError(ex)); + } + } + + view = new ResolvedView(xmlView, type.IsAvaloniaWindow(), new EquatableList(resolvedNames)); + } + } + + return new ResolvedClassInfo(view, hasDevToolsReference, new EquatableList(diagnostics)); + }) + .WithTrackingName(TrackingNames.ResolvedNamesProvider); + + context.RegisterSourceOutput(resolvedNames.Combine(options), static (context, pair) => + { + var (info, options) = pair; + + foreach (var diagnostic in info!.Diagnostics) + { + context.Report(diagnostic); + } + + if (info.View is { } view && options.AvaloniaNameGeneratorFilterByNamespace.Matches(view.Namespace)) + { + ICodeGenerator codeGenerator = options.AvaloniaNameGeneratorBehavior switch + { + Behavior.OnlyProperties => new OnlyPropertiesCodeGenerator( + options.AvaloniaNameGeneratorClassFieldModifier), + Behavior.InitializeComponent => new InitializeComponentCodeGenerator( + options.AvaloniaNameGeneratorAttachDevTools && info.CanAttachDevTools && view.IsWindow, + options.AvaloniaNameGeneratorClassFieldModifier), + _ => throw new ArgumentOutOfRangeException() + }; + var fileName = options.AvaloniaNameGeneratorViewFileNamingStrategy switch + { + ViewFileNamingStrategy.ClassName => $"{view.ClassName}.g.cs", + ViewFileNamingStrategy.NamespaceAndClassName => $"{view.Namespace}.{view.ClassName}.g.cs", + _ => throw new ArgumentOutOfRangeException( + nameof(ViewFileNamingStrategy), options.AvaloniaNameGeneratorViewFileNamingStrategy, + "Unknown naming strategy!") + }; + + var generatedPartialClass = codeGenerator.GenerateCode( + info.View.ClassName, + info.View.Namespace, + info.View.Names); + + context.AddSource(fileName, generatedPartialClass); + } + }); + } + + internal record XmlClassInfo( + ResolvedXmlView? XmlView, + EquatableList Diagnostics); + + internal record ResolvedClassInfo( + ResolvedView? View, + bool CanAttachDevTools, + EquatableList Diagnostics); +} diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs deleted file mode 100644 index e93895db2e..0000000000 --- a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -using Avalonia.Generators.Common; -using Avalonia.Generators.Common.Domain; -using Avalonia.Generators.Compiler; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -namespace Avalonia.Generators.NameGenerator; - -[Generator] -public class AvaloniaNameSourceGenerator : ISourceGenerator -{ - private const string SourceItemGroupMetadata = "build_metadata.AdditionalFiles.SourceItemGroup"; - - public void Initialize(GeneratorInitializationContext context) { } - - public void Execute(GeneratorExecutionContext context) - { - try - { - var generator = CreateNameGenerator(context); - if (generator is null) - { - return; - } - - var partials = generator.GenerateNameReferences(ResolveAdditionalFiles(context), context.CancellationToken); - foreach (var (fileName, content) in partials) - { - if(context.CancellationToken.IsCancellationRequested) - { - break; - } - - context.AddSource(fileName, content); - } - } - catch (OperationCanceledException) - { - } - catch (Exception exception) - { - context.ReportNameGeneratorUnhandledError(exception); - } - } - - private static IEnumerable ResolveAdditionalFiles(GeneratorExecutionContext context) - { - return context - .AdditionalFiles - .Where(f => context.AnalyzerConfigOptions - .GetOptions(f) - .TryGetValue(SourceItemGroupMetadata, out var sourceItemGroup) - && sourceItemGroup == "AvaloniaXaml"); - } - - private static INameGenerator? CreateNameGenerator(GeneratorExecutionContext context) - { - var options = new GeneratorOptions(context); - if (!options.AvaloniaNameGeneratorIsEnabled) - { - return null; - } - - var types = new RoslynTypeSystem((CSharpCompilation)context.Compilation); - ICodeGenerator generator = options.AvaloniaNameGeneratorBehavior switch { - Behavior.OnlyProperties => new OnlyPropertiesCodeGenerator(), - Behavior.InitializeComponent => new InitializeComponentCodeGenerator(types, options.AvaloniaNameGeneratorAttachDevTools), - _ => throw new ArgumentOutOfRangeException() - }; - - var compiler = MiniCompiler.CreateDefault(types, MiniCompiler.AvaloniaXmlnsDefinitionAttribute); - return new AvaloniaNameGenerator( - options.AvaloniaNameGeneratorViewFileNamingStrategy, - new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByPath), - new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByNamespace), - new XamlXViewResolver(types, compiler, true, - type => context.ReportNameGeneratorInvalidType(type), - error => context.ReportNameGeneratorUnhandledError(error)), - new XamlXNameResolver(options.AvaloniaNameGeneratorClassFieldModifier), - generator); - } -} diff --git a/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs index 5b44de43c1..10414c7959 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs @@ -1,12 +1,6 @@ -using System.Collections.Generic; -using System.Threading; -using Microsoft.CodeAnalysis; +using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; +using Microsoft.CodeAnalysis.Text; namespace Avalonia.Generators.NameGenerator; -internal interface INameGenerator -{ - IEnumerable GenerateNameReferences(IEnumerable additionalFiles, CancellationToken cancellationToken); -} - -internal record GeneratedPartialClass(string FileName, string Content); diff --git a/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs index 3dd058af0b..30b9d870aa 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs @@ -1,14 +1,13 @@ using System.Collections.Generic; using Avalonia.Generators.Common.Domain; -using XamlX.TypeSystem; namespace Avalonia.Generators.NameGenerator; -internal class InitializeComponentCodeGenerator : ICodeGenerator +internal class InitializeComponentCodeGenerator(bool avaloniaNameGeneratorAttachDevTools, NamedFieldModifier defaultNamedFieldModifier = NamedFieldModifier.Internal) : ICodeGenerator { private string _generatorName = typeof(InitializeComponentCodeGenerator).FullName; private string _generatorVersion = typeof(InitializeComponentCodeGenerator).Assembly.GetName().Version.ToString(); - private readonly bool _diagnosticsAreConnected; + private const string AttachDevToolsCodeBlock = @" #if DEBUG if (attachDevTools) @@ -22,12 +21,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator "; - public InitializeComponentCodeGenerator(IXamlTypeSystem types, bool avaloniaNameGeneratorAttachDevTools) - { - _diagnosticsAreConnected = avaloniaNameGeneratorAttachDevTools && types.FindAssembly("Avalonia.Diagnostics") != null; - } - - public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + public string GenerateCode(string className, string nameSpace, IEnumerable names) { var properties = new List(); var initializations = new List(); @@ -45,7 +39,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator var propertySource = $""" [global::System.CodeDom.Compiler.GeneratedCode("{_generatorName}", "{_generatorVersion}")] - {fieldModifier} {typeName} {name}; + {fieldModifier ?? defaultNamedFieldModifier.ToString().ToLowerInvariant()} {typeName} {name}; """; properties.Add(propertySource); initializations.Add($" {name} = __thisNameScope__?.Find<{typeName}>(\"{name}\");"); @@ -53,7 +47,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator hasNames = true; } - var attachDevTools = _diagnosticsAreConnected && IsWindow(xamlType); + var attachDevTools = avaloniaNameGeneratorAttachDevTools; return $@"// @@ -87,17 +81,4 @@ namespace {nameSpace} }} "; } - - private static bool IsWindow(IXamlType xamlType) - { - var type = xamlType; - bool isWindow; - do - { - isWindow = type.FullName == "Avalonia.Controls.Window"; - type = type.BaseType; - } while (!isWindow && type != null); - - return isWindow; - } } diff --git a/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs index 8b295acd6b..128af004a5 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs @@ -5,18 +5,18 @@ using XamlX.TypeSystem; namespace Avalonia.Generators.NameGenerator; -internal class OnlyPropertiesCodeGenerator : ICodeGenerator +internal class OnlyPropertiesCodeGenerator(NamedFieldModifier defaultNamedFieldModifier = NamedFieldModifier.Internal) : ICodeGenerator { private string _generatorName = typeof(OnlyPropertiesCodeGenerator).FullName; private string _generatorVersion = typeof(OnlyPropertiesCodeGenerator).Assembly.GetName().Version.ToString(); - public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + public string GenerateCode(string className, string nameSpace, IEnumerable names) { var namedControls = names .Select(info => " " + $"[global::System.CodeDom.Compiler.GeneratedCode(\"{_generatorName}\", \"{_generatorVersion}\")]\n" + " " + - $"{info.FieldModifier} {info.TypeName} {info.Name} => " + + $"{info.FieldModifier ?? defaultNamedFieldModifier.ToString().ToLowerInvariant()} {info.TypeName} {info.Name} => " + $"this.FindNameScope()?.Find<{info.TypeName}>(\"{info.Name}\");") .ToList(); var lines = string.Join("\n", namedControls); diff --git a/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs b/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs new file mode 100644 index 0000000000..09d894cbba --- /dev/null +++ b/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs @@ -0,0 +1,10 @@ +namespace Avalonia.Generators.NameGenerator; + +internal static class TrackingNames +{ + public const string ResolvedNamesProvider = nameof(ResolvedNamesProvider); + public const string XamlGeneratorOptionsProvider = nameof(XamlGeneratorOptionsProvider); + public const string InputXamlFilesProvider = nameof(InputXamlFilesProvider); + public const string ParsedXamlClasses = nameof(ParsedXamlClasses); + public const string XamlTypeSystem = nameof(XamlTypeSystem); +} diff --git a/src/tools/Avalonia.Generators/README.md b/src/tools/Avalonia.Generators/README.md index 73e9e71196..63ec7e8580 100644 --- a/src/tools/Avalonia.Generators/README.md +++ b/src/tools/Avalonia.Generators/README.md @@ -1,29 +1,10 @@ -[![NuGet Stats](https://img.shields.io/nuget/v/XamlNameReferenceGenerator.svg)](https://www.nuget.org/packages/XamlNameReferenceGenerator) [![downloads](https://img.shields.io/nuget/dt/XamlNameReferenceGenerator)](https://www.nuget.org/packages/XamlNameReferenceGenerator) ![Build](https://github.com/avaloniaui/Avalonia.NameGenerator/workflows/Build/badge.svg) ![License](https://img.shields.io/github/license/avaloniaui/Avalonia.NameGenerator.svg) ![Size](https://img.shields.io/github/repo-size/avaloniaui/Avalonia.NameGenerator.svg) - ### C# `SourceGenerator` for Typed Avalonia `x:Name` References This is a [C# `SourceGenerator`](https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/) built for generating strongly-typed references to controls with `x:Name` (or just `Name`) attributes declared in XAML (or, in `.axaml`). The source generator will look for the `xaml` (or `axaml`) file with the same name as your partial C# class that is a subclass of `Avalonia.INamed` and parses the XAML markup, finds all XAML tags with `x:Name` attributes and generates the C# code. ### Getting Started -In order to get started, just install the NuGet package: - -``` -dotnet add package XamlNameReferenceGenerator -``` - -Or, if you are using [submodules](https://git-scm.com/docs/git-submodule), you can reference the generator as such: - -```xml - - - - - -``` +In order to get started, just create project with Avalonia NuGet package: ### Usage diff --git a/src/tools/DevAnalyzers/DevAnalyzers.csproj b/src/tools/DevAnalyzers/DevAnalyzers.csproj index 6044eb6d2d..5f6f6ef62c 100644 --- a/src/tools/DevAnalyzers/DevAnalyzers.csproj +++ b/src/tools/DevAnalyzers/DevAnalyzers.csproj @@ -3,15 +3,7 @@ netstandard2.0 enable - True - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + diff --git a/src/tools/DevGenerators/DevGenerators.csproj b/src/tools/DevGenerators/DevGenerators.csproj index 8d856d5fc5..f1af2d8bba 100644 --- a/src/tools/DevGenerators/DevGenerators.csproj +++ b/src/tools/DevGenerators/DevGenerators.csproj @@ -4,18 +4,12 @@ netstandard2.0 enable false - True - true - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + diff --git a/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj b/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj index eeb953d753..7270a54c25 100644 --- a/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj +++ b/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj @@ -10,8 +10,8 @@ - + diff --git a/tests/Avalonia.Generators.Tests/CompilationUtils.cs b/tests/Avalonia.Generators.Tests/CompilationUtils.cs new file mode 100644 index 0000000000..3094b65b28 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/CompilationUtils.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; +using Avalonia.Generators.Compiler; +using Microsoft.CodeAnalysis; + +namespace Avalonia.Generators.Tests; + +internal static class CompilationUtils +{ + internal static IEnumerable ResolveNames(this IEnumerable names, Compilation compilation, XamlXNameResolver nameResolver) + { + var compiler = MiniCompiler.CreateRoslyn(new RoslynTypeSystem(compilation), MiniCompiler.AvaloniaXmlnsDefinitionAttribute); + return names + .Select(xmlName => + { + var clrType = compiler.ResolveXamlType(xmlName.XmlType); + return (clrType, nameResolver.ResolveName(clrType, xmlName.Name, xmlName.FieldModifier)); + }) + .Where(t => t.clrType.IsAvaloniaStyledElement()) + .Select(t => t.Item2); + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs b/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs index 15fb282ed9..4e98f3c207 100644 --- a/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs @@ -1,5 +1,8 @@ +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; using Avalonia.Generators.Compiler; using Avalonia.Generators.NameGenerator; using Avalonia.Generators.Tests.InitializeComponent.GeneratedInitializeComponent; @@ -23,7 +26,6 @@ public class InitializeComponentTests [InlineData(InitializeComponentCode.FieldModifier, View.FieldModifier, false)] [InlineData(InitializeComponentCode.AttachedPropsWithDevTools, View.AttachedProps, true)] [InlineData(InitializeComponentCode.AttachedProps, View.AttachedProps, false)] - [InlineData(InitializeComponentCode.ControlWithoutWindow, View.ControlWithoutWindow, true)] [InlineData(InitializeComponentCode.ControlWithoutWindow, View.ControlWithoutWindow, false)] public async Task Should_Generate_FindControl_Refs_From_Avalonia_Markup_File( string expectation, @@ -31,28 +33,28 @@ public class InitializeComponentTests bool devToolsMode) { var excluded = devToolsMode ? null : "Avalonia.Diagnostics"; - var compilation = - View.CreateAvaloniaCompilation(excluded) - .WithCustomTextBox(); - var types = new RoslynTypeSystem(compilation); - var classResolver = new XamlXViewResolver( - types, - MiniCompiler.CreateDefault( - new RoslynTypeSystem(compilation), - MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); + // Step 1: parse XAML as xml nodes, without any type information. + var classResolver = new XamlXViewResolver(MiniCompiler.CreateNoop()); var xaml = await View.Load(markup); - var classInfo = classResolver.ResolveView(xaml); + var classInfo = classResolver.ResolveView(xaml, CancellationToken.None); Assert.NotNull(classInfo); var nameResolver = new XamlXNameResolver(); - var names = nameResolver.ResolveNames(classInfo.Xaml); + var names = nameResolver.ResolveXmlNames(classInfo.Xaml, CancellationToken.None); + + // Step 2: use compilation context to resolve types + var compilation = + View.CreateAvaloniaCompilation(excluded) + .WithCustomTextBox(); + var resolvedNames = names.ResolveNames(compilation, nameResolver).ToArray(); - var generator = new InitializeComponentCodeGenerator(types, devToolsMode); + // Step 3: run generator + var generator = new InitializeComponentCodeGenerator(devToolsMode); var generatorVersion = typeof(InitializeComponentCodeGenerator).Assembly.GetName().Version?.ToString(); var code = generator - .GenerateCode("SampleView", "Sample.App", classInfo.XamlType, names) + .GenerateCode("SampleView", "Sample.App", resolvedNames) .Replace("\r", string.Empty); var expected = (await InitializeComponentCode.Load(expectation)) diff --git a/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs b/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs index a54fc7bf12..041e830674 100644 --- a/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs +++ b/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs @@ -21,7 +21,7 @@ public class MiniCompilerTests { var xaml = XDocumentXamlParser.Parse(MiniValidXaml); var compilation = CreateBasicCompilation(MiniClass); - MiniCompiler.CreateDefault(new RoslynTypeSystem(compilation)).Transform(xaml); + MiniCompiler.CreateRoslyn(new RoslynTypeSystem(compilation)).Transform(xaml); Assert.NotNull(xaml.Root); } @@ -31,7 +31,7 @@ public class MiniCompilerTests { var xaml = XDocumentXamlParser.Parse(AvaloniaXaml); var compilation = View.CreateAvaloniaCompilation(); - MiniCompiler.CreateDefault(new RoslynTypeSystem(compilation)).Transform(xaml); + MiniCompiler.CreateRoslyn(new RoslynTypeSystem(compilation)).Transform(xaml); Assert.NotNull(xaml.Root); } diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs b/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs index 3f498c2be2..e0db63526b 100644 --- a/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs @@ -1,5 +1,8 @@ +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; using Avalonia.Generators.Compiler; using Avalonia.Generators.NameGenerator; using Avalonia.Generators.Tests.OnlyProperties.GeneratedCode; @@ -25,27 +28,27 @@ public class OnlyPropertiesTests [InlineData(OnlyPropertiesCode.ControlWithoutWindow, View.ControlWithoutWindow)] public async Task Should_Generate_FindControl_Refs_From_Avalonia_Markup_File(string expectation, string markup) { - var compilation = - View.CreateAvaloniaCompilation() - .WithCustomTextBox(); - - var classResolver = new XamlXViewResolver( - new RoslynTypeSystem(compilation), - MiniCompiler.CreateDefault( - new RoslynTypeSystem(compilation), - MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); + // Step 1: parse XAML as xml nodes, without any type information. + var classResolver = new XamlXViewResolver(MiniCompiler.CreateNoop()); var xaml = await View.Load(markup); - var classInfo = classResolver.ResolveView(xaml); + var classInfo = classResolver.ResolveView(xaml, CancellationToken.None); Assert.NotNull(classInfo); var nameResolver = new XamlXNameResolver(); - var names = nameResolver.ResolveNames(classInfo.Xaml); + var names = nameResolver.ResolveXmlNames(classInfo.Xaml, CancellationToken.None); + + // Step 2: use compilation context to resolve types + var compilation = + View.CreateAvaloniaCompilation() + .WithCustomTextBox(); + var resolvedNames = names.ResolveNames(compilation, nameResolver).ToArray(); + // Step 3: run generator var generator = new OnlyPropertiesCodeGenerator(); var generatorVersion = typeof(OnlyPropertiesCodeGenerator).Assembly.GetName().Version?.ToString(); var code = generator - .GenerateCode("SampleView", "Sample.App", classInfo.XamlType, names) + .GenerateCode("SampleView", "Sample.App", resolvedNames) .Replace("\r", string.Empty); var expected = (await OnlyPropertiesCode.Load(expectation)) diff --git a/tests/Avalonia.Generators.Tests/XamlXClassResolverTests.cs b/tests/Avalonia.Generators.Tests/XamlXClassResolverTests.cs index 8d6db6ce47..6419488fd7 100644 --- a/tests/Avalonia.Generators.Tests/XamlXClassResolverTests.cs +++ b/tests/Avalonia.Generators.Tests/XamlXClassResolverTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using Avalonia.Generators.Common; using Avalonia.Generators.Compiler; @@ -23,17 +24,9 @@ public class XamlXClassResolverTests public async Task Should_Resolve_Base_Class_From_Xaml_File(string nameSpace, string className, string markup) { var xaml = await View.Load(markup); - var compilation = View - .CreateAvaloniaCompilation() - .WithCustomTextBox() - .WithBaseView(); + var resolver = new XamlXViewResolver(MiniCompiler.CreateNoop()); - var types = new RoslynTypeSystem(compilation); - var resolver = new XamlXViewResolver( - types, - MiniCompiler.CreateDefault(types, MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); - - var resolvedClass = resolver.ResolveView(xaml); + var resolvedClass = resolver.ResolveView(xaml, CancellationToken.None); Assert.NotNull(resolvedClass); Assert.Equal(className, resolvedClass.ClassName); Assert.Equal(nameSpace, resolvedClass.Namespace); diff --git a/tests/Avalonia.Generators.Tests/XamlXNameResolverTests.cs b/tests/Avalonia.Generators.Tests/XamlXNameResolverTests.cs index 46bd75d643..5af5ba4b00 100644 --- a/tests/Avalonia.Generators.Tests/XamlXNameResolverTests.cs +++ b/tests/Avalonia.Generators.Tests/XamlXNameResolverTests.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Generators.Common; @@ -6,6 +8,7 @@ using Avalonia.Generators.Common.Domain; using Avalonia.Generators.Compiler; using Avalonia.ReactiveUI; using Avalonia.Generators.Tests.Views; +using Microsoft.CodeAnalysis; using Xunit; namespace Avalonia.Generators.Tests; @@ -123,20 +126,19 @@ public class XamlXNameResolverTests private static IReadOnlyList ResolveNames(string xaml) { + var nameResolver = new XamlXNameResolver(); + + // Step 1: parse XAML as xml nodes, without any type information. + var classResolver = new XamlXViewResolver(MiniCompiler.CreateNoop()); + var classInfo = classResolver.ResolveView(xaml, CancellationToken.None); + Assert.NotNull(classInfo); + var names = nameResolver.ResolveXmlNames(classInfo.Xaml, CancellationToken.None); + + // Step 2: use compilation context to resolve types var compilation = View.CreateAvaloniaCompilation() .WithCustomTextBox() .WithBaseView(); - - var classResolver = new XamlXViewResolver( - new RoslynTypeSystem(compilation), - MiniCompiler.CreateDefault( - new RoslynTypeSystem(compilation), - MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); - - var classInfo = classResolver.ResolveView(xaml); - Assert.NotNull(classInfo); - var nameResolver = new XamlXNameResolver(); - return nameResolver.ResolveNames(classInfo.Xaml); + return names.ResolveNames(compilation, nameResolver).ToArray(); } } From f218fb73120d06832dfc763dd04308eda009832c Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 14 Aug 2025 00:37:39 +0200 Subject: [PATCH 015/154] Increase RemoteProtocolTests timeout (#19461) --- tests/Avalonia.DesignerSupport.Tests/RemoteProtocolTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Avalonia.DesignerSupport.Tests/RemoteProtocolTests.cs b/tests/Avalonia.DesignerSupport.Tests/RemoteProtocolTests.cs index d9dd799419..06011b13a6 100644 --- a/tests/Avalonia.DesignerSupport.Tests/RemoteProtocolTests.cs +++ b/tests/Avalonia.DesignerSupport.Tests/RemoteProtocolTests.cs @@ -18,6 +18,8 @@ namespace Avalonia.DesignerSupport.Tests { public class RemoteProtocolTests : IDisposable { + private const int TimeoutInMs = 1000; + private readonly List _disposables = new List(); private IAvaloniaRemoteTransportConnection _server; private IAvaloniaRemoteTransportConnection _client; @@ -68,7 +70,7 @@ namespace Avalonia.DesignerSupport.Tests object TakeServer() { - var src = new CancellationTokenSource(200); + var src = new CancellationTokenSource(TimeoutInMs); try { return _serverMessages.Take(src.Token); @@ -132,7 +134,7 @@ namespace Avalonia.DesignerSupport.Tests foreach (var p in t.GetProperties()) p.SetValue(o, GetRandomValue(p.PropertyType, $"{t.FullName}.{p.Name}")); - _client.Send(o).Wait(200); + _client.Send(o).Wait(TimeoutInMs); var received = TakeServer(); Helpers.StructDiff(received, o); From e75f06d63f286f99c1e2054d5310c27ad2eb0659 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Thu, 14 Aug 2025 17:12:50 +0900 Subject: [PATCH 016/154] Track TabIndex value in NumericUpDown (#19348) * Track TabIndex value in NumericUpDown * Use template * Add TabNavigationProperty.OverrideDefaultValue --- .../NumericUpDown/NumericUpDown.cs | 2 ++ .../NumericUpDownTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs index eac1403554..90cfb41c65 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -379,6 +379,7 @@ namespace Avalonia.Controls FocusableProperty.OverrideDefaultValue(true); IsTabStopProperty.OverrideDefaultValue(false); + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Local); } /// @@ -408,6 +409,7 @@ namespace Avalonia.Controls if (TextBox != null) { TextBox.Text = Text; + TextBox[!TabIndexProperty] = this[!TabIndexProperty]; TextBox.PointerPressed += TextBoxOnPointerPressed; _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged()); } diff --git a/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs index 03213cd0d1..f03bd5b633 100644 --- a/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs @@ -152,5 +152,22 @@ namespace Avalonia.Controls.UnitTests }.RegisterInNameScope(scope); }); } + + [Fact] + public void TabIndex_Should_Be_Synchronized_With_Inner_TextBox() + { + RunTest((control, textbox) => + { + // Set TabIndex on NumericUpDown + control.TabIndex = 5; + + // The inner TextBox should inherit the same TabIndex + Assert.Equal(5, textbox.TabIndex); + + // Change TabIndex and verify it gets synchronized + control.TabIndex = 10; + Assert.Equal(10, textbox.TabIndex); + }); + } } } From 46d5a693f1b62e101691f965145ccec0fd01cea2 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 14 Aug 2025 16:44:04 +0800 Subject: [PATCH 017/154] Capture ExecutionContext for Dispatcher.InvokeAsync (#19163) * Capture ExecutionContext for Dispatcher.InvokeAsync (cherry picked from commit e41c9272ac45afc31007a8fe26f9f56291b063ef) * Implement CulturePreservingExecutionContext * Add IsFlowSuppressed checking * Add NET6_0_OR_GREATER because only the Restore need it. * Use `ExecutionContext.Run` instead of `ExecutionContext.Restore`. * Pass this to avoid lambda capture. * Use ExecutionContext directly on NET6_0_OR_GREATER * on NET6_0_OR_GREATER, use Restore so we can get a simple stack trace. * Add unit tests. * All test code must run inside Task.Run to avoid interfering with the test * First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. This way, if a later test fails, we have the .NET framework's baseline behavior for reference. --- .../CulturePreservingExecutionContext.cs | 156 +++++++++++ .../Threading/DispatcherOperation.cs | 48 +++- .../DispatcherTests.cs | 251 +++++++++++++++--- 3 files changed, 403 insertions(+), 52 deletions(-) create mode 100644 src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs diff --git a/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs b/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs new file mode 100644 index 0000000000..ec0ebaa4a6 --- /dev/null +++ b/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs @@ -0,0 +1,156 @@ +#if NET6_0_OR_GREATER +// In .NET Core, the security context and call context are not supported, however, +// the impersonation context and culture would typically flow with the execution context. +// See: https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext +// +// So we can safely use ExecutionContext without worrying about culture flowing issues. +#else +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; + +namespace Avalonia.Threading; + +/// +/// An ExecutionContext that preserves culture information across async operations. +/// This is a modernized version that removes legacy compatibility switches and +/// includes nullable reference type annotations. +/// +internal sealed class CulturePreservingExecutionContext +{ + private readonly ExecutionContext _context; + private CultureAndContext? _cultureAndContext; + + private CulturePreservingExecutionContext(ExecutionContext context) + { + _context = context; + } + + /// + /// Captures the current ExecutionContext and culture information. + /// + /// A new CulturePreservingExecutionContext instance, or null if no context needs to be captured. + public static CulturePreservingExecutionContext? Capture() + { + // ExecutionContext.SuppressFlow had been called. + // We expect ExecutionContext.Capture() to return null, so match that behavior and return null. + if (ExecutionContext.IsFlowSuppressed()) + { + return null; + } + + var context = ExecutionContext.Capture(); + if (context == null) + return null; + + return new CulturePreservingExecutionContext(context); + } + + /// + /// Runs the specified callback in the captured execution context while preserving culture information. + /// This method is used for .NET Framework and earlier .NET versions. + /// + /// The execution context to run in. + /// The callback to execute. + /// The state to pass to the callback. + public static void Run(CulturePreservingExecutionContext executionContext, ContextCallback callback, object? state) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (callback == null) + return; + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (executionContext == null) + ThrowNullContext(); + + // Save culture information - we will need this to restore just before + // the callback is actually invoked from CallbackWrapper. + executionContext._cultureAndContext = CultureAndContext.Initialize(callback, state); + + try + { + ExecutionContext.Run( + executionContext._context, + s_callbackWrapperDelegate, + executionContext._cultureAndContext); + } + finally + { + // Restore culture information - it might have been modified during callback execution. + executionContext._cultureAndContext.RestoreCultureInfos(); + } + } + + [DoesNotReturn] + private static void ThrowNullContext() + { + throw new InvalidOperationException("ExecutionContext cannot be null."); + } + + private static readonly ContextCallback s_callbackWrapperDelegate = CallbackWrapper; + + /// + /// Executes the callback and saves culture values immediately afterwards. + /// + /// Contains the actual callback and state. + private static void CallbackWrapper(object? obj) + { + var cultureAndContext = (CultureAndContext)obj!; + + // Restore culture information saved during Run() + cultureAndContext.RestoreCultureInfos(); + + try + { + // Execute the actual callback + cultureAndContext.Callback(cultureAndContext.State); + } + finally + { + // Save any culture changes that might have occurred during callback execution + cultureAndContext.CaptureCultureInfos(); + } + } + + /// + /// Helper class to manage culture information across execution contexts. + /// + private sealed class CultureAndContext + { + public ContextCallback Callback { get; } + public object? State { get; } + + private CultureInfo? _culture; + private CultureInfo? _uiCulture; + + private CultureAndContext(ContextCallback callback, object? state) + { + Callback = callback; + State = state; + CaptureCultureInfos(); + } + + public static CultureAndContext Initialize(ContextCallback callback, object? state) + { + return new CultureAndContext(callback, state); + } + + public void CaptureCultureInfos() + { + _culture = Thread.CurrentThread.CurrentCulture; + _uiCulture = Thread.CurrentThread.CurrentUICulture; + } + + public void RestoreCultureInfos() + { + if (_culture != null) + Thread.CurrentThread.CurrentCulture = _culture; + + if (_uiCulture != null) + Thread.CurrentThread.CurrentUICulture = _uiCulture; + } + } +} +#endif diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index 14b0614113..3a4513652e 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -5,6 +5,12 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +#if NET6_0_OR_GREATER +using ExecutionContext = System.Threading.ExecutionContext; +#else +using ExecutionContext = Avalonia.Threading.CulturePreservingExecutionContext; +#endif + namespace Avalonia.Threading; [DebuggerDisplay("{DebugDisplay}")] @@ -28,18 +34,19 @@ public class DispatcherOperation protected internal object? Callback; protected object? TaskSource; - + internal DispatcherOperation? SequentialPrev { get; set; } internal DispatcherOperation? SequentialNext { get; set; } internal DispatcherOperation? PriorityPrev { get; set; } internal DispatcherOperation? PriorityNext { get; set; } internal PriorityChain? Chain { get; set; } - + internal bool IsQueued => Chain != null; private EventHandler? _aborted; private EventHandler? _completed; private DispatcherPriority _priority; + private readonly ExecutionContext? _executionContext; internal DispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, Action callback, bool throwOnUiThread) : this(dispatcher, priority, throwOnUiThread) @@ -52,6 +59,7 @@ public class DispatcherOperation ThrowOnUiThread = throwOnUiThread; Priority = priority; Dispatcher = dispatcher; + _executionContext = ExecutionContext.Capture(); } internal string DebugDisplay @@ -103,7 +111,7 @@ public class DispatcherOperation _completed += value; } } - + remove { lock(Dispatcher.InstanceLock) @@ -112,7 +120,7 @@ public class DispatcherOperation } } } - + public bool Abort() { if (Dispatcher.Abort(this)) @@ -155,7 +163,7 @@ public class DispatcherOperation // we throw an exception instead. throw new InvalidOperationException("A thread cannot wait on operations already running on the same thread."); } - + var cts = new CancellationTokenSource(); EventHandler finishedHandler = delegate { @@ -241,7 +249,7 @@ public class DispatcherOperation } public Task GetTask() => GetTaskCore(); - + /// /// Returns an awaiter for awaiting the completion of the operation. /// @@ -259,21 +267,35 @@ public class DispatcherOperation AbortTask(); _aborted?.Invoke(this, EventArgs.Empty); } - + internal void Execute() { Debug.Assert(Status == DispatcherOperationStatus.Executing); try { using (AvaloniaSynchronizationContext.Ensure(Dispatcher, Priority)) - InvokeCore(); + { + if (_executionContext is { } executionContext) + { +#if NET6_0_OR_GREATER + ExecutionContext.Restore(executionContext); + InvokeCore(); +#else + ExecutionContext.Run(executionContext, static s => ((DispatcherOperation)s!).InvokeCore(), this); +#endif + } + else + { + InvokeCore(); + } + } } finally { _completed?.Invoke(this, EventArgs.Empty); } } - + protected virtual void InvokeCore() { try @@ -305,7 +327,7 @@ public class DispatcherOperation } internal virtual object? GetResult() => null; - + protected virtual void AbortTask() { object? taskSource; @@ -401,14 +423,14 @@ internal sealed class SendOrPostCallbackDispatcherOperation : DispatcherOperatio { private readonly object? _arg; - internal SendOrPostCallbackDispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, - SendOrPostCallback callback, object? arg, bool throwOnUiThread) + internal SendOrPostCallbackDispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, + SendOrPostCallback callback, object? arg, bool throwOnUiThread) : base(dispatcher, priority, throwOnUiThread) { Callback = callback; _arg = arg; } - + protected override void InvokeCore() { try diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 1884a1ab65..6e3bc55e29 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,7 +29,7 @@ public partial class DispatcherTests public event Action Timer; public long? NextTimer { get; private set; } public bool AskedForSignal { get; private set; } - + public void UpdateTimer(long? dueTimeInTicks) { NextTimer = dueTimeInTicks; @@ -79,16 +80,16 @@ public partial class DispatcherTests ReadyForBackgroundProcessing?.Invoke(); } } - + class SimpleControlledDispatcherImpl : SimpleDispatcherWithBackgroundProcessingImpl, IControlledDispatcherImpl { private readonly bool _useTestTimeout = true; private readonly CancellationToken? _cancel; public int RunLoopCount { get; private set; } - + public SimpleControlledDispatcherImpl() { - + } public SimpleControlledDispatcherImpl(CancellationToken cancel, bool useTestTimeout = false) @@ -96,7 +97,7 @@ public partial class DispatcherTests _useTestTimeout = useTestTimeout; _cancel = cancel; } - + public void RunLoop(CancellationToken token) { RunLoopCount++; @@ -114,8 +115,8 @@ public partial class DispatcherTests } - - + + [Fact] public void DispatcherExecutesJobsAccordingToPriority() { @@ -129,7 +130,7 @@ public partial class DispatcherTests impl.ExecuteSignal(); Assert.Equal(new[] { "Render", "Input", "Background" }, actions); } - + [Fact] public void DispatcherPreservesOrderWhenChangingPriority() { @@ -139,13 +140,13 @@ public partial class DispatcherTests var toPromote = disp.InvokeAsync(()=>actions.Add("PromotedRender"), DispatcherPriority.Background); var toPromote2 = disp.InvokeAsync(()=>actions.Add("PromotedRender2"), DispatcherPriority.Input); disp.Post(() => actions.Add("Render"), DispatcherPriority.Render); - + toPromote.Priority = DispatcherPriority.Render; toPromote2.Priority = DispatcherPriority.Render; - + Assert.True(impl.AskedForSignal); impl.ExecuteSignal(); - + Assert.Equal(new[] { "PromotedRender", "PromotedRender2", "Render" }, actions); } @@ -178,7 +179,7 @@ public partial class DispatcherTests var expectedCount = (c + 1) * 3; if (c == 3) expectedCount = 10; - + Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) @@ -189,8 +190,8 @@ public partial class DispatcherTests Assert.Null(impl.NextTimer); } } - - + + [Fact] public void DispatcherStopsItemProcessingWhenInputIsPending() { @@ -225,7 +226,7 @@ public partial class DispatcherTests 3 => 10, _ => throw new InvalidOperationException($"Unexpected value {c}") }; - + Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) @@ -255,7 +256,7 @@ public partial class DispatcherTests foreground ? DispatcherPriority.Default : DispatcherPriority.Background).Wait(); Assert.True(finished); - if (controlled) + if (controlled) Assert.Equal(foreground ? 0 : 1, ((SimpleControlledDispatcherImpl)impl).RunLoopCount); } @@ -271,7 +272,7 @@ public partial class DispatcherTests Dispatcher.ResetForUnitTests(); SynchronizationContext.SetSynchronizationContext(null); } - + public void Dispose() { Dispatcher.ResetForUnitTests(); @@ -279,7 +280,7 @@ public partial class DispatcherTests SynchronizationContext.SetSynchronizationContext(null); } } - + [Fact] public void ExitAllFramesShouldExitAllFramesAndBeAbleToContinue() { @@ -301,10 +302,10 @@ public partial class DispatcherTests disp.MainLoop(CancellationToken.None); - + Assert.Equal(new[] { "Nested frame", "ExitAllFrames", "Nested frame exited" }, actions); actions.Clear(); - + var secondLoop = new CancellationTokenSource(); disp.Post(() => { @@ -315,8 +316,8 @@ public partial class DispatcherTests Assert.Equal(new[] { "Callback after exit" }, actions); } } - - + + [Fact] public void ShutdownShouldExitAllFramesAndNotAllowNewFrames() { @@ -335,7 +336,7 @@ public partial class DispatcherTests actions.Add("Shutdown"); disp.BeginInvokeShutdown(DispatcherPriority.Normal); }); - + disp.Post(() => { actions.Add("Nested frame after shutdown"); @@ -343,12 +344,12 @@ public partial class DispatcherTests Dispatcher.UIThread.MainLoop(CancellationToken.None); actions.Add("Nested frame after shutdown exited"); }); - + var criticalFrameAfterShutdown = new DispatcherFrame(false); disp.Post(() => { actions.Add("Critical frame after shutdown"); - + Dispatcher.UIThread.PushFrame(criticalFrameAfterShutdown); actions.Add("Critical frame after shutdown exited"); }); @@ -362,7 +363,7 @@ public partial class DispatcherTests Assert.Equal(new[] { - "Nested frame", + "Nested frame", "Shutdown", // Normal nested frames are supposed to exit immediately "Nested frame after shutdown", "Nested frame after shutdown exited", @@ -372,7 +373,7 @@ public partial class DispatcherTests "Nested frame exited" }, actions); actions.Clear(); - + disp.Post(()=>actions.Add("Frame after shutdown finished")); Assert.Throws(() => disp.MainLoop(CancellationToken.None)); Assert.Empty(actions); @@ -388,7 +389,7 @@ public partial class DispatcherTests return base.Wait(waitHandles, waitAll, millisecondsTimeout); } } - + [Fact] public void DisableProcessingShouldStopProcessing() { @@ -407,7 +408,7 @@ public partial class DispatcherTests SynchronizationContext.SetSynchronizationContext(avaloniaContext); var waitHandle = new ManualResetEvent(true); - + helper.WaitCount = 0; waitHandle.WaitOne(100); Assert.Equal(0, helper.WaitCount); @@ -431,8 +432,8 @@ public partial class DispatcherTests void DumpCurrentPriority() => priorities.Add(((AvaloniaSynchronizationContext)SynchronizationContext.Current!).Priority); - - + + disp.Post(DumpCurrentPriority, DispatcherPriority.Normal); disp.Post(DumpCurrentPriority, DispatcherPriority.Loaded); disp.Post(DumpCurrentPriority, DispatcherPriority.Input); @@ -467,34 +468,34 @@ public partial class DispatcherTests public void DispatcherInvokeAsyncUnwrapsTasks() { int asyncMethodStage = 0; - + async Task AsyncMethod() { asyncMethodStage = 1; await Task.Delay(200); asyncMethodStage = 2; } - + async Task AsyncMethodWithResult() { await Task.Delay(100); return 1; } - + async Task Test() { await Dispatcher.UIThread.InvokeAsync(AsyncMethod); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult)); asyncMethodStage = 0; - + await Dispatcher.UIThread.InvokeAsync(AsyncMethod, DispatcherPriority.Default); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult, DispatcherPriority.Default)); - + Dispatcher.UIThread.ExitAllFrames(); } - + using (new DispatcherServices(new ManagedDispatcherImpl(null))) { var t = Test(); @@ -505,8 +506,8 @@ public partial class DispatcherTests t.GetAwaiter().GetResult(); } } - - + + [Fact] public async Task DispatcherResumeContinuesOnUIThread() { @@ -605,4 +606,176 @@ public partial class DispatcherTests Dispatcher.UIThread.MainLoop(tokenSource.Token); } + +#nullable enable + private class AsyncLocalTestClass + { + public AsyncLocal AsyncLocalField { get; set; } = new AsyncLocal(); + } + + [Fact] + public async Task ExecutionContextIsPreservedInDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test1 = null; + string? test2 = null; + string? test3 = null; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + var testObject = new AsyncLocalTestClass(); + + // Test 1: Verify Task.Run preserves the execution context. + // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. + // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. + testObject.AsyncLocalField.Value = "Initial Value"; + var task1 = Task.Run(() => + { + test1 = testObject.AsyncLocalField.Value; + }); + + // Test 2: Verify Invoke preserves the execution context. + testObject.AsyncLocalField.Value = "Initial Value"; + Dispatcher.UIThread.Invoke(() => + { + test2 = testObject.AsyncLocalField.Value; + }); + + // Test 3: Verify InvokeAsync preserves the execution context. + testObject.AsyncLocalField.Value = "Initial Value"; + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test3 = testObject.AsyncLocalField.Value; + }); + + _ = Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.WhenAll(task1); + tokenSource.Cancel(); + }); + + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // Invoke(): Always passes because the context is not changed. + Assert.Equal("Initial Value", test1); + // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("Initial Value", test2); + // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 + Assert.Equal("Initial Value", test3); + } + + [Fact] + public async Task ExecutionContextIsNotPreservedAmongDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test = null; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + var testObject = new AsyncLocalTestClass(); + + // Test: Verify that InvokeAsync calls do not share execution context between each other. + _ = Dispatcher.UIThread.InvokeAsync(() => + { + testObject.AsyncLocalField.Value = "Initial Value"; + }); + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test = testObject.AsyncLocalField.Value; + }); + + _ = Dispatcher.UIThread.InvokeAsync(() => + { + tokenSource.Cancel(); + }); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // The value should NOT flow between different InvokeAsync execution contexts. + Assert.Null(test); + } + + [Fact] + public async Task ExecutionContextCultureInfoIsPreservedInDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test1 = null; + string? test2 = null; + string? test3 = null; + var oldCulture = Thread.CurrentThread.CurrentCulture; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + // This culture tag is Sumerian and is extremely unlikely to be set as the default on any device, + // ensuring that this test will not be affected by the user's environment. + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("sux-Shaw-UM"); + + // Test 1: Verify Task.Run preserves the culture in the execution context. + // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. + // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. + var task1 = Task.Run(() => + { + test1 = Thread.CurrentThread.CurrentCulture.Name; + }); + + // Test 2: Verify Invoke preserves the execution context. + Dispatcher.UIThread.Invoke(() => + { + test2 = Thread.CurrentThread.CurrentCulture.Name; + }); + + // Test 3: Verify InvokeAsync preserves the culture in the execution context. + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test3 = Thread.CurrentThread.CurrentCulture.Name; + }); + + _ = Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.WhenAll(task1); + tokenSource.Cancel(); + }); + }); + + try + { + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // Invoke(): Always passes because the context is not changed. + Assert.Equal("sux-Shaw-UM", test1); + // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("sux-Shaw-UM", test2); + // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 + Assert.Equal("sux-Shaw-UM", test3); + } + finally + { + Thread.CurrentThread.CurrentCulture = oldCulture; + // Ensure that this test does not have a negative impact on other tests. + Assert.NotEqual("sux-Shaw-UM", oldCulture.Name); + } + } +#nullable restore + } From 34e6d1457709a7f1b8626d2699316f65fbc13541 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 14 Aug 2025 10:58:58 +0200 Subject: [PATCH 018/154] Remove non implemented message AvnView.resetPressedMouseButtons (#19445) --- native/Avalonia.Native/src/OSX/AvnView.h | 1 - native/Avalonia.Native/src/OSX/WindowBaseImpl.mm | 1 - 2 files changed, 2 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnView.h b/native/Avalonia.Native/src/OSX/AvnView.h index c80805a15c..030330c908 100644 --- a/native/Avalonia.Native/src/OSX/AvnView.h +++ b/native/Avalonia.Native/src/OSX/AvnView.h @@ -19,7 +19,6 @@ -(AvnPoint) translateLocalPoint:(AvnPoint)pt; -(void) onClosed; -(void) setModifiers:(NSEventModifierFlags)modifierFlags; --(void) resetPressedMouseButtons; -(AvnPlatformResizeReason) getResizeReason; -(void) setResizeReason:(AvnPlatformResizeReason)reason; diff --git a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm index 6553e8f460..07ee404d0f 100644 --- a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm +++ b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm @@ -450,7 +450,6 @@ HRESULT WindowBaseImpl::BeginDragAndDropOperation(AvnDragDropEffects effects, Av op |= NSDragOperationLink; if ((ieffects & (int) AvnDragDropEffects::Move) != 0) op |= NSDragOperationMove; - [View resetPressedMouseButtons]; [View beginDraggingSessionWithItems:@[dragItem] event:nsevent source:CreateDraggingSource((NSDragOperation) op, cb, sourceHandle)]; return S_OK; From cb79e5d7bb1bb7f3dd1cebf4a3dd6b0bc76f5a4b Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 15 Aug 2025 15:31:26 +0800 Subject: [PATCH 019/154] Fix comment for #19163 (#19469) --- tests/Avalonia.Base.UnitTests/DispatcherTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 6e3bc55e29..92871db682 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -664,9 +664,9 @@ public partial class DispatcherTests await Task.WhenAll(task); // Assertions - // Invoke(): Always passes because the context is not changed. - Assert.Equal("Initial Value", test1); // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("Initial Value", test1); + // Invoke: Always passes because the context is not changed. Assert.Equal("Initial Value", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("Initial Value", test3); @@ -762,9 +762,9 @@ public partial class DispatcherTests await Task.WhenAll(task); // Assertions - // Invoke(): Always passes because the context is not changed. - Assert.Equal("sux-Shaw-UM", test1); // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("sux-Shaw-UM", test1); + // Invoke: Always passes because the context is not changed. Assert.Equal("sux-Shaw-UM", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("sux-Shaw-UM", test3); From 4f666f0ba1432555a0e41a438cabe42be3aeed47 Mon Sep 17 00:00:00 2001 From: Vladislav Pozdniakov Date: Fri, 15 Aug 2025 12:21:01 +0100 Subject: [PATCH 020/154] Fix macOS thick titlebar mouse event duplication (#19447) * Added failing test for OSXThickTitleBar single title area click produce double click #19320 * Fixed OSXThickTitleBar title area click duplication and event delays until the event-tracking-loop is completed * IntegrationTestApp. Move event counter controls to a separate column. Fixes Changing_Size_Should_Not_Change_Position test * Move pointer tests to Default group to avoid interference * Try to fix CI crash * Try disabling test * Fix CI test. Collection back to Default * CI fix. Return back empty test. * CI fix. Minimal bug test * CI test. Add double click test * CI fix. Remove double click test. --------- Co-authored-by: Julien Lebosquain --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 98 +++++++------------ .../IntegrationTestApp/ShowWindowTest.axaml | 18 ++-- .../ShowWindowTest.axaml.cs | 27 +++++ .../ElementExtensions.cs | 5 + .../PointerTests_MacOS.cs | 53 +++++++++- 5 files changed, 134 insertions(+), 67 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 03daa2f296..79fe095731 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -35,6 +35,7 @@ bool _canBecomeKeyWindow; bool _isExtended; bool _isTransitioningToFullScreen; + bool _isTitlebarSession; AvnMenu* _menu; IAvnAutomationPeer* _automationPeer; AvnAutomationNode* _automationNode; @@ -501,68 +502,10 @@ return NO; } -- (void)forwardToAvnView:(NSEvent *)event -{ - auto parent = _parent.tryGetWithCast(); - if (!parent) { - return; - } - - switch(event.type) { - case NSEventTypeLeftMouseDown: - [parent->View mouseDown:event]; - break; - case NSEventTypeLeftMouseUp: - [parent->View mouseUp:event]; - break; - case NSEventTypeLeftMouseDragged: - [parent->View mouseDragged:event]; - break; - case NSEventTypeRightMouseDown: - [parent->View rightMouseDown:event]; - break; - case NSEventTypeRightMouseUp: - [parent->View rightMouseUp:event]; - break; - case NSEventTypeRightMouseDragged: - [parent->View rightMouseDragged:event]; - break; - case NSEventTypeOtherMouseDown: - [parent->View otherMouseDown:event]; - break; - case NSEventTypeOtherMouseUp: - [parent->View otherMouseUp:event]; - break; - case NSEventTypeOtherMouseDragged: - [parent->View otherMouseDragged:event]; - break; - case NSEventTypeMouseMoved: - [parent->View mouseMoved:event]; - break; - default: - break; - } -} - - (void)sendEvent:(NSEvent *_Nonnull)event { - // Event-tracking loop for thick titlebar mouse events - if (event.type == NSEventTypeLeftMouseDown && [self isPointInTitlebar:event.locationInWindow]) - { - NSEventMask mask = NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp; - NSEvent *ev = event; - while (ev.type != NSEventTypeLeftMouseUp) - { - [self forwardToAvnView:ev]; - [super sendEvent:ev]; - ev = [NSApp nextEventMatchingMask:mask - untilDate:[NSDate distantFuture] - inMode:NSEventTrackingRunLoopMode - dequeue:YES]; - } - [self forwardToAvnView:ev]; - [super sendEvent:ev]; - return; + if (event.type == NSEventTypeLeftMouseDown) { + _isTitlebarSession = [self isPointInTitlebar:event.locationInWindow]; } [super sendEvent:event]; @@ -603,6 +546,37 @@ } break; + case NSEventTypeLeftMouseDragged: + case NSEventTypeMouseMoved: + case NSEventTypeLeftMouseUp: + { + // Usually NSToolbar events are passed natively to AvnView when the mouse is inside the control. + // When a drag operation started in NSToolbar leaves the control region, the view does not get any + // events. We will detect this scenario and pass events ourselves. + + if(!_isTitlebarSession || [self isPointInTitlebar:event.locationInWindow]) + break; + + AvnView* view = parent->View; + + if(!view) + break; + + if(event.type == NSEventTypeLeftMouseDragged) + { + [view mouseDragged:event]; + } + else if(event.type == NSEventTypeMouseMoved) + { + [view mouseMoved:event]; + } + else if(event.type == NSEventTypeLeftMouseUp) + { + [view mouseUp:event]; + } + } + break; + case NSEventTypeMouseEntered: { parent->UpdateCursor(); @@ -618,6 +592,10 @@ default: break; } + + if(event.type == NSEventTypeLeftMouseUp) { + _isTitlebarSession = NO; + } } } diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml b/samples/IntegrationTestApp/ShowWindowTest.axaml index c43e00497f..272c61ed0c 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml @@ -15,7 +15,7 @@ - + @@ -62,13 +62,19 @@