From e45b62ae66e586cf7fbb5a5ffd0c7f54496d1b3b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sun, 14 Apr 2024 21:53:54 -0700 Subject: [PATCH] Implement EventSetter --- src/Avalonia.Base/Styling/EventSetter.cs | 72 ++++++++++ .../Styling/EventSetterInstance.cs | 45 ++++++ .../AvaloniaXamlIlCompiler.cs | 1 + .../AvaloniaXamlIlEventSetterTransformer.cs | 133 ++++++++++++++++++ .../AvaloniaXamlIlWellKnownTypes.cs | 6 + .../SetterTests.cs | 54 ++++++- 6 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/Avalonia.Base/Styling/EventSetter.cs create mode 100644 src/Avalonia.Base/Styling/EventSetterInstance.cs create mode 100644 src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlEventSetterTransformer.cs diff --git a/src/Avalonia.Base/Styling/EventSetter.cs b/src/Avalonia.Base/Styling/EventSetter.cs new file mode 100644 index 0000000000..6650357a0c --- /dev/null +++ b/src/Avalonia.Base/Styling/EventSetter.cs @@ -0,0 +1,72 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.PropertyStore; +using Avalonia.Threading; + +namespace Avalonia.Styling; + +/// +/// Represents an event setter in a style. Event setters invoke the specified event handlers in response to events. +/// +public sealed class EventSetter : SetterBase, ISetterInstance +{ + /// + /// Initializes a new instance of the class. + /// + public EventSetter() + { + + } + + /// + /// Initializes a new instance of the class, using the provided event and handler parameters. + /// + /// The particular routed event that the responds to. + /// The handler to assign in this setter. + public EventSetter(RoutedEvent eventName, Delegate handler) + { + Event = eventName; + Handler = handler; + } + + /// + /// Gets or sets the particular routed event that this EventSetter responds to. + /// + public RoutedEvent? Event { get; set; } + + /// + /// Gets or sets the reference to a handler for a routed event in the setter. + /// + public Delegate? Handler { get; set; } + + /// + /// The routing strategies to listen to. + /// + public RoutingStrategies Routes { get; set; } = RoutingStrategies.Bubble | RoutingStrategies.Direct; + + /// + /// Gets or sets a value that determines whether the handler assigned to the setter should still be invoked, even if the event is marked handled in its event data. + /// + public bool HandledEventsToo { get; set; } + + internal override ISetterInstance Instance(IStyleInstance styleInstance, StyledElement target) + { + if (target is null) + throw new ArgumentNullException(nameof(target)); + + if (Event is null) + throw new InvalidOperationException($"{nameof(EventSetter)}.{nameof(Event)} must be set."); + + if (Handler is null) + throw new InvalidOperationException($"{nameof(EventSetter)}.{nameof(Handler)} must be set."); + + if (target is not InputElement inputElement) + throw new ArgumentException($"{nameof(EventSetter)} target must be a {nameof(InputElement)}", nameof(target)); + + if (styleInstance.HasActivator) + throw new InvalidOperationException("EventSetter cannot be used in styles with activators, i.e. styles with complex selectors."); + + return new EventSetterInstance(inputElement, Event, Handler, Routes, HandledEventsToo); + } +} diff --git a/src/Avalonia.Base/Styling/EventSetterInstance.cs b/src/Avalonia.Base/Styling/EventSetterInstance.cs new file mode 100644 index 0000000000..df1268fecf --- /dev/null +++ b/src/Avalonia.Base/Styling/EventSetterInstance.cs @@ -0,0 +1,45 @@ +using System; +using System.Reflection; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.PropertyStore; + +namespace Avalonia.Styling; + +internal class EventSetterInstance : ISetterInstance, IValueEntry +{ + private readonly InputElement _inputElement; + private readonly RoutedEvent _routedEvent; + private readonly Delegate _handler; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1010")] + private static readonly StyledProperty s_eventSetterProperty = AvaloniaProperty + .Register("EventSetter"); + + public EventSetterInstance(InputElement inputElement, RoutedEvent routedEvent, Delegate handler, + RoutingStrategies routes, bool handledEventsToo) + { + _inputElement = inputElement; + _routedEvent = routedEvent; + _handler = handler; + + inputElement.AddHandler(routedEvent, handler, routes, handledEventsToo); + } + + public AvaloniaProperty Property => s_eventSetterProperty; + public bool HasValue() => false; + public object GetValue() => BindingOperations.DoNothing; + + public bool GetDataValidationState(out BindingValueType state, out Exception? error) + { + state = BindingValueType.DoNothing; + error = null; + return false; + } + + public void Unsubscribe() + { + _inputElement.RemoveHandler(_routedEvent, _handler); + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs index 9d1f439bab..396296b034 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs @@ -62,6 +62,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions new AvaloniaXamlIlPropertyPathTransformer(), new AvaloniaXamlIlSetterTargetTypeMetadataTransformer(), new AvaloniaXamlIlSetterTransformer(), + new AvaloniaXamlIlEventSetterTransformer(), new AvaloniaXamlIlStyleValidatorTransformer(), new AvaloniaXamlIlConstructorServiceProviderTransformer(), new AvaloniaXamlIlTransitionsTypeMetadataTransformer(), diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlEventSetterTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlEventSetterTransformer.cs new file mode 100644 index 0000000000..ed5d78a457 --- /dev/null +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlEventSetterTransformer.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.Linq; +using XamlX.Ast; +using XamlX.Transform; +using XamlX.TypeSystem; + +namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers; + +internal class AvaloniaXamlIlEventSetterTransformer : IXamlAstTransformer +{ + public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) + { + if (!(node is XamlAstObjectNode on + && on.Type.GetClrType().FullName == "Avalonia.Styling.EventSetter")) + return node; + + IXamlType targetType = null; + + var styleParent = context.ParentNodes() + .OfType() + .FirstOrDefault(x => x.ScopeType == AvaloniaXamlIlTargetTypeMetadataNode.ScopeTypes.Style); + + if (styleParent != null) + { + targetType = styleParent.TargetType.GetClrType() + ?? throw new XamlStyleTransformException("Can not find parent Style Selector or ControlTemplate TargetType. If setter is not part of the style, you can set x:SetterTargetType directive on its parent.", node); + } + + if (targetType == null) + { + throw new XamlStyleTransformException("Could not determine target type of Setter", node); + } + + var routedEventProp = on.Children.OfType() + .FirstOrDefault(x => x.Property.GetClrProperty().Name == "Event"); + var routedEventType = context.GetAvaloniaTypes().RoutedEvent; + var routedEventTType = context.GetAvaloniaTypes().RoutedEventT; + IXamlType actualRoutedEventType; + if (routedEventProp != null) + { + if (routedEventProp.Values.OfType().FirstOrDefault() is not { } valueNode) + { + var eventName = routedEventProp.Values.OfType().FirstOrDefault()?.Text; + if (eventName == null) + throw new XamlStyleTransformException("EventSetter.Event must be an event name or x:Static expression to an event.", node); + + // Ideally, compiler should do one of these lookups: + // - Runtime lookup in RoutedEventRegistry. But can't do that in compiler. Could be the best approach, if we inject RoutedEventRegistry extra code, still no compile time alidation. + // - Lookup for CLR events with this name and routed args. But there might not be a CLR event at all. And we can't reliably get RoutedEvent instance from there. + // - Lookup for AddEventNameHandler methods. But the same - there might not be one. + // Instead this transformer searches for routed event definition in one of base classes. Not ideal too, it won't handle attached events. Still better than others. + // Combining with ability to use x:Static for uncommon use-cases this approach should work well. + if (!eventName.EndsWith("Event")) + { + eventName += "Event"; + } + + IXamlType type = targetType, nextType = targetType; + IXamlMember member = null; + while (nextType is not null && member is null) + { + type = nextType; + member = type?.Fields.FirstOrDefault(f => f.IsPublic && f.IsStatic && f.Name == eventName) ?? + (IXamlMember)type?.GetAllProperties().FirstOrDefault(p => + p.Name == eventName && p.Getter is { IsPublic: true, IsStatic: true }); + nextType = type.BaseType; + } + + if (member is null) + throw new XamlStyleTransformException($"EventSetter.Event with name \"{eventName}\" wasn't found.", node); + + valueNode = new XamlStaticExtensionNode(on, + new XamlAstClrTypeReference(routedEventProp, type, false), member.Name); + + routedEventProp.Values = new List {valueNode}; + } + + actualRoutedEventType = valueNode.Type.GetClrType(); + if (!routedEventType.IsAssignableFrom(actualRoutedEventType)) + throw new XamlStyleTransformException("EventSetter.Event must be assignable to RoutedEvent type.", node); + + // Get RoutedEvent or RoutedEvent base type from the field, so we can get generic parameter below. + // This helps to ignore any other possible MyRoutedEvent : RoutedEvent type definitions. + while (!(actualRoutedEventType.FullName.StartsWith(routedEventType.FullName) + || actualRoutedEventType.FullName.StartsWith(routedEventTType.FullName))) + { + actualRoutedEventType = actualRoutedEventType.BaseType; + } + } + else + { + throw new XamlStyleTransformException($"EventSetter.Event must be set.", node); + } + + var handlerProp = on.Children.OfType() + .FirstOrDefault(x => x.Property.GetClrProperty().Name == "Handler"); + if (handlerProp != null) + { + var handlerName = handlerProp.Values.OfType().FirstOrDefault()?.Text; + if (handlerName == null) + throw new XamlStyleTransformException("EventSetter.Handler must be a method name.", node); + + var rootType = context.RootObject?.Type.GetClrType(); + var argsType = actualRoutedEventType.GenericArguments.Any() ? + actualRoutedEventType.GenericArguments[0] : + context.GetAvaloniaTypes().RoutedEventArgs; + + var handler = rootType?.FindMethod( + handlerName, + context.Configuration.WellKnownTypes.Void, + true, + new[] { context.Configuration.WellKnownTypes.Object, argsType }); + if (handler != null) + { + var delegateType = context.Configuration.TypeSystem.GetType("System.EventHandler`1").MakeGenericType(argsType); + var handlerInvoker = new XamlLoadMethodDelegateNode(handlerProp, context.RootObject, delegateType, handler); + handlerProp.Values = new List { handlerInvoker }; + } + else + { + throw new XamlStyleTransformException( + $"EventSetter.Handler with name \"{handlerName}\" wasn't found on type \"{rootType?.Name ?? "(null)"}\"." + + $"EventSetter is supported only on XAML files with x:Class.", node); + } + } + else + { + throw new XamlStyleTransformException($"EventSetter.Handler must be set.", node); + } + + return on; + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs index 89d2518e2d..0e83c77aab 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs @@ -18,6 +18,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers public IXamlType AvaloniaProperty { get; } public IXamlType AvaloniaPropertyT { get; } public IXamlType StyledPropertyT { get; } + public IXamlType RoutedEvent { get; } + public IXamlType RoutedEventT { get; } + public IXamlType RoutedEventArgs { get; } public IXamlMethod AvaloniaObjectSetStyledPropertyValue { get; } public IXamlType AvaloniaAttachedPropertyT { get; } public IXamlType IBinding { get; } @@ -135,6 +138,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers AvaloniaProperty = cfg.TypeSystem.GetType("Avalonia.AvaloniaProperty"); AvaloniaPropertyT = cfg.TypeSystem.GetType("Avalonia.AvaloniaProperty`1"); StyledPropertyT = cfg.TypeSystem.GetType("Avalonia.StyledProperty`1"); + RoutedEvent = cfg.TypeSystem.GetType("Avalonia.Interactivity.RoutedEvent"); + RoutedEventT = cfg.TypeSystem.GetType("Avalonia.Interactivity.RoutedEvent`1"); + RoutedEventArgs = cfg.TypeSystem.GetType("Avalonia.Interactivity.RoutedEventArgs"); AvaloniaAttachedPropertyT = cfg.TypeSystem.GetType("Avalonia.AttachedProperty`1"); BindingPriority = cfg.TypeSystem.GetType("Avalonia.Data.BindingPriority"); AvaloniaObjectSetStyledPropertyValue = AvaloniaObject diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/SetterTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/SetterTests.cs index 6fc0f2d91c..ae172f738e 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/SetterTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/SetterTests.cs @@ -1,4 +1,7 @@ -using Avalonia.Controls; +using System; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Styling; using Avalonia.UnitTests; using Xunit; @@ -48,4 +51,53 @@ public class SetterTests : XamlTestBase Assert.Equal(typeof(ContentControl), setter.Property.OwnerType); } } + + [Theory] + [InlineData("{x:Static InputElement.KeyDownEvent}","OnKeyDown")] + [InlineData("KeyDown","OnKeyDown")] + [InlineData("KeyDown", "OnKeyDownUnspecific")] + [InlineData("KeyDown","OnKeyDownStatic")] + public void EventSetter_Should_Be_Registered(string eventName, string handlerName) + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (WindowWithEventHandler)AvaloniaRuntimeXamlLoader.Load($""" + + + + + +"""); + var callbackCalled = false; + window.Callback += _ => callbackCalled = true; + + window.Show(); + + window.RaiseEvent(new KeyEventArgs() { RoutedEvent = InputElement.KeyDownEvent }); + + Assert.True(callbackCalled); + } + } +} + +public class WindowWithEventHandler : Window +{ + public Action Callback; + public void OnKeyDown(object sender, KeyEventArgs e) + { + Callback?.Invoke(e); + } + public void OnKeyDownUnspecific(object sender, RoutedEventArgs e) + { + Callback?.Invoke(e); + } + public static void OnKeyDownStatic(object sender, RoutedEventArgs e) + { + ((WindowWithEventHandler)sender).Callback?.Invoke(e); + } }