diff --git a/build_logic/src/main/kotlin/Constants.kt b/build_logic/src/main/kotlin/Constants.kt index 45e01ea..6cafb43 100644 --- a/build_logic/src/main/kotlin/Constants.kt +++ b/build_logic/src/main/kotlin/Constants.kt @@ -2,7 +2,7 @@ data class Developer(val name: String, val email: String) object Constants { const val GROUP = "dev.yumi.commons" - const val VERSION = "1.0.0-alpha.1" + const val VERSION = "1.0.0-alpha.2" const val JAVA_VERSION = 17 const val PROJECT_NAME = "Yumi Commons" diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/Event.java b/libraries/event/src/main/java/dev/yumi/commons/event/Event.java index 9b7c9db..86c950f 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/Event.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/Event.java @@ -27,6 +27,8 @@ package dev.yumi.commons.event; import dev.yumi.commons.collections.toposort.NodeSorting; +import dev.yumi.commons.collections.toposort.SortableNode; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; @@ -52,7 +54,7 @@ * of implementing an invoker and only allows listener implementations to be done by implementing an interface onto a * class or extending a class. *

- * An Event can have phases, each listener is attributed to a phase ({@link Event#getDefaultPhaseId()} if unspecified), + * An Event can have phases, each listener is attributed to a phase ({@link Event#defaultPhaseId()} if unspecified), * and each phase can have a defined ordering. Each event phase is identified, ordering is done * by explicitly stating that event phase {@code A} will run before event phase {@code B}, for example. * See {@link Event#addPhaseOrdering(Comparable, Comparable)} for more information. @@ -114,11 +116,13 @@ * } * * @param the phase identifier type - * @param the type of the invoker used to execute an event and the type of the listener + * @param the type of the listeners, and the type of the invoker used to execute an event * @version 1.0.0 * @since 1.0.0 */ -public final class Event, T> { +public sealed class Event, T> + implements InvokableEvent + permits FilteredEvent { /** * The type of listener of this event. */ @@ -130,8 +134,8 @@ public final class Event, T> { /** * The function used to generate the implementation of the invoker to call the listeners. */ - private final Function invokerFactory; - private final Lock lock = new ReentrantLock(); + final Function invokerFactory; + final Lock lock = new ReentrantLock(); /** * The invoker to execute the callbacks. */ @@ -139,15 +143,15 @@ public final class Event, T> { /** * The registered listeners. */ - private T[] listeners; + T[] listeners; /** * The registered event phases. */ - private final Map> phases = new LinkedHashMap<>(); + final Map> phases = new LinkedHashMap<>(); /** * The event phases sorted in a way that satisfies dependencies. */ - private final List> sortedPhases = new ArrayList<>(); + final List> sortedPhases = new ArrayList<>(); @SuppressWarnings("unchecked") Event( @@ -170,7 +174,7 @@ public final class Event, T> { * {@return the class of the kind of listeners accepted by this event} */ @Contract(pure = true) - public @NotNull Class getType() { + public @NotNull Class type() { return this.type; } @@ -178,7 +182,7 @@ public final class Event, T> { * {@return the default phase identifier of this event} */ @Contract(pure = true) - public @NotNull I getDefaultPhaseId() { + public @NotNull I defaultPhaseId() { return this.defaultPhaseId; } @@ -235,44 +239,42 @@ public void addPhaseOrdering(@NotNull I firstPhase, @NotNull I secondPhase) { var first = this.getOrCreatePhase(firstPhase, false); var second = this.getOrCreatePhase(secondPhase, false); - EventPhaseData.link(first, second); - NodeSorting.sort(this.sortedPhases, "event phases"); + PhaseData.link(first, second); + this.sortPhases(); this.rebuildInvoker(this.listeners.length); } finally { this.lock.unlock(); } } - /** - * {@return the invoker instance used to execute this event} - *

- * The result of this method should not be stored since the invoker may become invalid - * at any time. Always call this method when you intend to execute an event. - */ - @Contract(pure = true) + @Override public @NotNull T invoker() { return this.invoker; } /* Implementation */ - private EventPhaseData getOrCreatePhase(I id, boolean sortIfCreate) { + PhaseData getOrCreatePhase(@NotNull I id, boolean sortIfCreate) { var phase = this.phases.get(id); if (phase == null) { - phase = new EventPhaseData<>(id, this.type); + phase = new PhaseData<>(id, this.type); this.phases.put(id, phase); this.sortedPhases.add(phase); if (sortIfCreate) { - NodeSorting.sort(this.sortedPhases, "event phases"); + this.sortPhases(); } } return phase; } - private void rebuildInvoker(int newLength) { + void sortPhases() { + NodeSorting.sort(this.sortedPhases, "event phases"); + } + + void rebuildInvoker(int newLength) { if (this.sortedPhases.size() == 1) { // There's a single phase, so we can directly use its listeners. this.listeners = this.sortedPhases.get(0).listeners; @@ -297,7 +299,7 @@ private void rebuildInvoker(int newLength) { this.update(); } - private void update() { + void update() { // Make a copy of the array given to the invoker factory so the entries cannot be mutated. this.invoker = this.invokerFactory.apply( Arrays.copyOf(this.listeners, this.listeners.length) @@ -315,4 +317,36 @@ public String toString() { ", sortedPhases=" + this.sortedPhases + '}'; } + + /** + * Represents data for a specific event phase. + * + * @param the phase identifier type + * @param the type of the listeners + */ + @ApiStatus.Internal + static sealed class PhaseData extends SortableNode> + permits FilteredEvent.FilteredPhaseData { + private final I id; + T[] listeners; + + @SuppressWarnings("unchecked") + PhaseData(@NotNull I id, @NotNull Class listenerType) { + Objects.requireNonNull(id); + + this.id = id; + this.listeners = (T[]) Array.newInstance(listenerType, 0); + } + + @Override + public @NotNull I getId() { + return this.id; + } + + void addListener(@NotNull T listener) { + int oldLength = this.listeners.length; + this.listeners = Arrays.copyOf(this.listeners, oldLength + 1); + this.listeners[oldLength] = listener; + } + } } diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java b/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java index b958249..c88313b 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java @@ -36,6 +36,7 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Function; +import java.util.function.Supplier; /** * Represents an {@link Event} manager. @@ -62,7 +63,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * Creates a new instance of {@link Event}. * * @param invokerFactory the factory which generates an invoker implementation using an array of listeners - * @param the type of the invoker executed by the event + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(Class, Function) @@ -86,8 +87,8 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * and whenever a listener returns another value it returns early. * * - * @param type the class representing the type of the invoker that is executed by the event - * @param the type of the invoker executed by the event + * @param type the class representing the type of the listeners of the event + * @param the type of the listeners of the event * @return a new event instance * @see #create(InvokerFactory) * @see #create(Class, Function) @@ -104,9 +105,9 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas /** * Creates a new instance of {@link Event}. * - * @param type the class representing the type of the invoker that is executed by the event + * @param type the class representing the type of the listeners of the event * @param implementation a function which generates an invoker implementation using an array of listeners - * @param the type of the invoker executed by the event + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(InvokerFactory) @@ -129,10 +130,10 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * as the render or tick loops. Otherwise, the other {@link #create(Class, Function)} method should work * in 99% of cases with little to no performance overhead. * - * @param type the class representing the type of the invoker that is executed by the event + * @param type the class representing the type of the listeners of the event * @param emptyImplementation the implementation of T to use when the array event has no listener registrations * @param implementation a function which generates an invoker implementation using an array of listeners - * @param the type of the invoker executed by the event + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(InvokerFactory) @@ -171,8 +172,8 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * * @param invokerFactory the factory which generates an invoker implementation using an array of listeners * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} - * @param the type of the invoker executed by the event + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(InvokerFactory) @@ -205,10 +206,10 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas *

* This method uses an automatically generated invoker implementation. * - * @param type the class representing the type of the invoker that is executed by the event + * @param type the class representing the type of the listeners of the event * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} - * @param the type of the invoker executed by the event + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(InvokerFactory) @@ -239,11 +240,11 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas *

* Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. * - * @param type the class representing the type of the invoker that is executed by the event + * @param type the class representing the type of the listeners of the event * @param implementation a function which generates an invoker implementation using an array of listeners * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} - * @param the type of the invoker executed by the event + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event * @return a new event instance * @see #create(Class) * @see #create(InvokerFactory) @@ -258,20 +259,234 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas @NotNull Function implementation, @NotNull I... defaultPhases ) { - this.ensureContainsDefaultPhase(defaultPhases); - YumiAssertions.ensureNoDuplicates(defaultPhases, id -> new IllegalArgumentException("Duplicate event phase: " + id)); + return this.createWithPhases(() -> new Event<>(type, this.defaultPhaseId, implementation), defaultPhases); + } - var event = this.create(type, implementation); + /** + * Creates a new instance of {@link FilteredEvent}. + * + * @param contextType the class of the context + * @param invokerFactory the factory which generates an invoker implementation using an array of listeners + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, Class, Function) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + */ + public @NotNull FilteredEvent createFiltered(@NotNull Class contextType, @NotNull InvokerFactory invokerFactory) { + return this.createFiltered(invokerFactory.type(), contextType, invokerFactory); + } - for (int i = 1; i < defaultPhases.length; ++i) { - event.addPhaseOrdering(defaultPhases[i - 1], defaultPhases[i]); - } + /** + * Creates a new instance of {@link FilteredEvent} for which the invoker implementation is automatically generated. + *

+ * The invoker implementation is automatically generated given the following conditions: + *

+ * + * @param type the class representing the type of the listeners of the event + * @param contextType the class of the context + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Function) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + * @see DefaultInvokerFactory the invoker factory used for this event + */ + public @NotNull FilteredEvent createFiltered(@NotNull Class type, @NotNull Class contextType) { + return this.createFiltered(type, contextType, new DefaultInvokerFactory<>(type)); + } + /** + * Creates a new instance of {@link FilteredEvent}. + * + * @param type the class representing the type of the listeners of the event + * @param contextType the class of the context + * @param implementation a function which generates an invoker implementation using an array of listeners + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + */ + public @NotNull FilteredEvent createFiltered( + @NotNull Class type, + @NotNull Class contextType, + @NotNull Function implementation + ) { + var event = new FilteredEvent(type, this.defaultPhaseId, implementation); this.creationEvent.invoker().onEventCreation(this, event); - return event; } + /** + * Creates a new instance of {@link FilteredEvent}. + *

+ * This method adds a {@code emptyImplementation} parameter which provides an implementation of the invoker + * when no listeners are registered. Generally this method should only be used when the code path is very hot, such + * as the render or tick loops. Otherwise, the other {@link #createFiltered(Class, Class, Function)} method should work + * in 99% of cases with little to no performance overhead. + * + * @param type the class representing the type of the listeners of the event + * @param contextType the class of the context + * @param emptyImplementation the implementation of T to use when the array event has no listener registrations + * @param implementation a function which generates an invoker implementation using an array of listeners + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + */ + public @NotNull FilteredEvent createFiltered( + @NotNull Class type, + @NotNull Class contextType, + @NotNull T emptyImplementation, + @NotNull Function implementation + ) { + return this.createFiltered(type, contextType, listeners -> switch (listeners.length) { + case 0 -> emptyImplementation; + case 1 -> listeners[0]; + // We can ensure the implementation may not remove elements from the backing array since the array given to + // this method is a copy of the backing array. + default -> implementation.apply(listeners); + }); + } + + /** + * Create a new instance of {@link FilteredEvent} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + * + * @param contextType the class of the context + * @param invokerFactory the factory which generates an invoker implementation using an array of listeners + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Function) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + */ + @SuppressWarnings("unchecked") + public @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class contextType, + @NotNull InvokerFactory invokerFactory, + @NotNull I... defaultPhases + ) { + return this.createFilteredWithPhases(invokerFactory.type(), contextType, invokerFactory, defaultPhases); + } + + /** + * Create a new instance of {@link FilteredEvent} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + *

+ * This method uses an automatically generated invoker implementation. + * + * @param type the class representing the type of the invoker that is executed by the event + * @param contextType the class of the context + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Function) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Function, Comparable[]) + */ + @SuppressWarnings("unchecked") + public @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class type, + @NotNull Class contextType, + @NotNull I... defaultPhases + ) { + return this.createFilteredWithPhases(type, contextType, new DefaultInvokerFactory<>(type), defaultPhases); + } + + /** + * Create a new instance of {@link FilteredEvent} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + * + * @param type the class representing the type of the invoker that is executed by the event + * @param contextType the class of the context + * @param implementation a function which generates an invoker implementation using an array of listeners + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the listeners of the event + * @param the type of the filtering context + * @return a new filtered event instance + * @see #createFiltered(Class, Class) + * @see #createFiltered(Class, InvokerFactory) + * @see #createFiltered(Class, Class, Function) + * @see #createFiltered(Class, Class, Object, Function) + * @see #createFilteredWithPhases(Class, InvokerFactory, Comparable[]) + * @see #createFilteredWithPhases(Class, Class, Comparable[]) + */ + @SafeVarargs + public final @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class type, + @NotNull Class contextType, + @NotNull Function implementation, + @NotNull I... defaultPhases + ) { + return this.createWithPhases(() -> new FilteredEvent<>(type, this.defaultPhaseId, implementation), defaultPhases); + } + /** * Registers the listener to the specified events. *

@@ -296,20 +511,20 @@ public final void listenAll(Object listener, Event... events) { // Check whether we actually can register stuff. We only commit the registration if all events can. for (var event : events) { - if (!event.getType().isAssignableFrom(listener.getClass())) { + if (!event.type().isAssignableFrom(listener.getClass())) { throw new IllegalArgumentException("Given object " + listener + " is not a listener of event " + event); } - if (event.getType().getTypeParameters().length > 0) { + if (event.type().getTypeParameters().length > 0) { throw new IllegalArgumentException("Cannot register a listener for the event " + event + " which is using generic parameters with listenAll."); } - listenedPhases.putIfAbsent(event.getType(), this.defaultPhaseId); + listenedPhases.putIfAbsent(event.type(), this.defaultPhaseId); } // We can register, so we do! for (var event : events) { - ((Event) event).register(listenedPhases.get(event.getType()), listener); + ((Event) event).register(listenedPhases.get(event.type()), listener); } } @@ -317,7 +532,7 @@ public final void listenAll(Object listener, Event... events) { * {@return the default phase identifier of all the events created by this event manager} */ @Contract(pure = true) - public @NotNull I getDefaultPhaseId() { + public @NotNull I defaultPhaseId() { return this.defaultPhaseId; } @@ -329,6 +544,26 @@ public final void listenAll(Object listener, Event... events) { return this.creationEvent; } + /* Implementation */ + + private > E createWithPhases( + Supplier eventSupplier, + @NotNull I... defaultPhases + ) { + this.ensureContainsDefaultPhase(defaultPhases); + YumiAssertions.ensureNoDuplicates(defaultPhases, id -> new IllegalArgumentException("Duplicate event phase: " + id)); + + var event = eventSupplier.get(); + + for (int i = 1; i < defaultPhases.length; i++) { + event.addPhaseOrdering(defaultPhases[i - 1], defaultPhases[i]); + } + + this.creationEvent.invoker().onEventCreation(this, event); + + return event; + } + private Map, I> getListenedPhases(Class listenerClass) { var map = new HashMap, I>(); diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/EventPhaseData.java b/libraries/event/src/main/java/dev/yumi/commons/event/EventPhaseData.java deleted file mode 100644 index 0dc251b..0000000 --- a/libraries/event/src/main/java/dev/yumi/commons/event/EventPhaseData.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2023 Yumi Project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.yumi.commons.event; - -import dev.yumi.commons.collections.toposort.SortableNode; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -import java.lang.reflect.Array; -import java.util.Arrays; -import java.util.Objects; - -@ApiStatus.Internal -final class EventPhaseData extends SortableNode> { - private final I id; - T[] listeners; - - @SuppressWarnings("unchecked") - EventPhaseData(@NotNull I id, @NotNull Class listenerType) { - Objects.requireNonNull(id); - - this.id = id; - this.listeners = (T[]) Array.newInstance(listenerType, 0); - } - - @Override - public @NotNull I getId() { - return this.id; - } - - public void addListener(@NotNull T listener) { - int oldLength = this.listeners.length; - this.listeners = Arrays.copyOf(this.listeners, oldLength + 1); - this.listeners[oldLength] = listener; - } -} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java b/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java new file mode 100644 index 0000000..90c3949 --- /dev/null +++ b/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java @@ -0,0 +1,264 @@ +/* + * Copyright 2024 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Represents an {@linkplain Event event} which can filter its listeners given an invocation context. + *

+ * This type of event will respect the same rules and assumptions as regular events. + * + *

Example: Registering filtered listeners

+ *

+ * Similar to how you would register a listener in a regular event, you pass an instance of {@code T} into {@link #register}. + * Using the same {@link #register(Object) register} methods from {@link Event} will result in registering a global listener + * which will be invoked no matter the context. + * To make your listener context-specific you need to add a predicate given a context of type {@code C}. + * + *

{@code
+ * // Events are created and managed by an EventManager.
+ * // They are given a type for the phase identifiers and the default phase identifier.
+ * static final EventManager EVENT_MANAGER = new EventManager("default");
+ *
+ * // Events should use a dedicated functional interface for T rather than overloading multiple events to the same type
+ * // to allow those who implement using a class to implement multiple events.
+ * @FunctionalInterface
+ * public interface Example {
+ *     void doSomething();
+ * }
+ *
+ * // Filtered events also have an invocation context.
+ * public record EventContext(String value) {}
+ *
+ * // You can also return this instance of Event from a method, may be useful where a parameter is needed to get
+ * // the right instance of Event.
+ * public static final Event EXAMPLE = EVENT_MANAGER.create(Example.class);
+ *
+ * public void registerEvents() {
+ *     // Since T is a functional interface, we can use the lambda form.
+ *     EXAMPLE.register(() -> {
+ *         // Do something
+ *     }, context -> context.value().equals("test"));
+ *
+ *     // Or we can use a method reference.
+ *     EXAMPLE.register(this::runSomething, context -> context.value().equals("some other context"));
+ *
+ *     // Or implement T using a class.
+ *     // You can also use an anonymous class here; for brevity that is not included.
+ *     EXAMPLE.register(new ImplementedOntoClass()); // This is a global listener.
+ * }
+ *
+ * public void runSomething() {
+ *     // Do something else
+ * }
+ *
+ * // When implementing onto a class, the class must implement the same type as the event invoker.
+ * class ImplementedOntoClass implements Example {
+ *     public void doSomething() {
+ *         // Do something else again
+ *     }
+ * }
+ * }
+ * + *

Example: Executing a filtered event

+ *

+ * While you could execute a filtered event the same way a regular event is executed, + * it would only invoke global listeners due to it not being aware of a context. + * Executing a filtered event is done by first creating a subset of listeners from the event using {@link #forContext(Object)} for a given context, + * then calling a method on the event invoker. Where {@code T} is Example, executing a filtered event + * is done through the following: + * + *

{@code
+ * InvokerSubset subset = EXAMPLE.forContext(new EventContext("test"));
+ *
+ * // Invoke the listeners relevant to the given context.
+ * subset.invoker().doSomething();
+ * }
+ *

+ * This architecture has advantages only if the subset is stored and only re-created if the context is different. + * Otherwise, a regular {@linkplain Event event} would do just fine. + * + * @param the phase identifier type + * @param the type of the listeners, and the type of the invoker used to execute an event + * @param the type of the context used to filter out which listeners should be invoked + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ +public final class FilteredEvent, T, C> extends Event { + /** + * Reference queue for cleared invoker subsets. + */ + private final ReferenceQueue> queue = new ReferenceQueue<>(); + /** + * A cache of currently alive invoker subsets. + */ + private final Set>> subsets = new HashSet<>(); + + FilteredEvent(@NotNull Class type, @NotNull I defaultPhaseId, @NotNull Function invokerFactory) { + super(type, defaultPhaseId, invokerFactory); + } + + /** + * Registers a listener to this event, which will be called only if the execution context matches the filter. + * + * @param listener the listener to register + * @param filter the predicate to test whether the listener to register should be called within a given context + * @see #register(Object) + * @see #register(Comparable, Object) + * @see #register(Comparable, Object, Predicate) + */ + public void register(@NotNull T listener, @NotNull Predicate filter) { + this.register(this.defaultPhaseId(), listener, filter); + } + + /** + * Registers a listener to this event for a specific phase, which will called only if the execution context matches the filter. + * + * @param phaseIdentifier the identifier of the phase to register the listener in + * @param listener the listener to register + * @param filter the predicate to test whether the listener to register should be called within a given context + * @see #register(Object) + * @see #register(Object, Predicate) + * @see #register(Comparable, Object) + */ + public void register(@NotNull I phaseIdentifier, @NotNull T listener, @NotNull Predicate filter) { + Objects.requireNonNull(phaseIdentifier, "Cannot register a listener for a null phase."); + Objects.requireNonNull(listener, "Cannot register a null listener."); + Objects.requireNonNull(filter, "Cannot register a listener with a null context filter."); + + this.lock.lock(); + try { + this.getOrCreatePhase(phaseIdentifier, true).addListener(new Listener<>(listener, filter)); + this.rebuildInvoker(this.listeners.length); + } finally { + this.lock.unlock(); + } + } + + /** + * Creates an invoker for a subset of listeners that matches the given context. + *

+ * Each subset will be dynamically updated if new listeners which match the given context are registered to this event. + * + * @param context the current context + * @return an invoker for a subset of listeners + */ + public SubsetInvoker forContext(C context) { + var subset = new SubsetInvoker<>(this, context); + + this.lock.lock(); + try { + this.purge(); + this.subsets.add(new WeakReference<>(subset, this.queue)); + } finally { + this.lock.unlock(); + } + + return subset; + } + + // This may be marked as redundant in some IDEs, but javac is clear: there is an unchecked warning here. + // Though, that warning is very weird: the generics of FilteredPhaseData match this event's generics + // while the generics in the phase fields also match them. + @SuppressWarnings({"unchecked", "RedundantSuppression"}) + @Override + FilteredPhaseData getOrCreatePhase(@NotNull I id, boolean sortIfCreate) { + var phase = this.phases.get(id); + + if (phase == null) { + phase = new FilteredPhaseData(id, this.type()); + this.phases.put(id, phase); + this.sortedPhases.add(phase); + + if (sortIfCreate) { + this.sortPhases(); + } + } + + return (FilteredPhaseData) phase; + } + + @Override + void rebuildInvoker(int newLength) { + super.rebuildInvoker(newLength); + + this.purge(); + + for (var subset : this.subsets) { + var value = subset.get(); + + if (value != null) { + value.rebuildInvoker(); + } + } + } + + private void purge() { + for (var ref = this.queue.poll(); ref != null; ref = this.queue.poll()) { + //noinspection SuspiciousMethodCalls + this.subsets.remove(ref); + } + } + + @ApiStatus.Internal + final class FilteredPhaseData extends Event.PhaseData { + Listener[] listenersData; + + @SuppressWarnings("unchecked") + FilteredPhaseData(@NotNull I id, @NotNull Class listenerType) { + super(id, listenerType); + this.listenersData = new Listener[0]; + } + + @Override + void addListener(@NotNull T listener) { + super.addListener(listener); + this.addListener(new Listener<>(listener, null)); + } + + void addListener(@NotNull Listener listener) { + int oldLength = this.listenersData.length; + this.listenersData = Arrays.copyOf(this.listenersData, oldLength + 1); + this.listenersData[oldLength] = listener; + } + } + + /** + * Represents a listener. + * + * @param listener the listener itself + * @param selector the selector of this listener, may be null if this listener is global + * @param the type of listener + * @param the type of filtering context + */ + @ApiStatus.Internal + record Listener(T listener, @Nullable Predicate selector) { + /** + * {@return {@code true} if this listener should listen to the given context, or {@code false} otherwise} + * + * @param context the filtering context + */ + boolean shouldListen(C context) { + return this.selector == null || this.selector.test(context); + } + } +} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java b/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java new file mode 100644 index 0000000..4503b4a --- /dev/null +++ b/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * Represents an event or event-like object which can be invoked by the same method as their listeners. + * + * @param the type of the listeners, and the type of the invoker used to execute an event + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ +public interface InvokableEvent { + /** + * {@return the invoker instance used to execute this event} + *

+ * The result of this method should not be stored since the invoker may become invalid + * at any time. Always call this method when you intend to execute an event. + */ + @Contract(pure = true) + @NotNull T invoker(); +} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/SubsetInvoker.java b/libraries/event/src/main/java/dev/yumi/commons/event/SubsetInvoker.java new file mode 100644 index 0000000..93e5a8d --- /dev/null +++ b/libraries/event/src/main/java/dev/yumi/commons/event/SubsetInvoker.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event; + +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Array; +import java.util.ArrayList; + +/** + * Represents a subset of listeners of a {@linkplain FilteredEvent filtered event}. + * + * @param the phase identifier type + * @param the type of the listeners, and the type of the invoker used to execute an event + * @param the type of the context used to filter out which listeners should be invoked by this subset + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ +public final class SubsetInvoker, T, C> implements InvokableEvent { + /** + * The parent filtered event from which the subset comes from. + */ + private final FilteredEvent parent; + /** + * The context relevant to this subset of listeners. + */ + private final C context; + /** + * The invoker to execute the callbacks. + */ + private volatile T invoker; + + public SubsetInvoker(FilteredEvent parent, C context) { + this.parent = parent; + this.context = context; + this.rebuildInvoker(); + } + + @Override + public @NotNull T invoker() { + return this.invoker; + } + + @SuppressWarnings("unchecked") + void rebuildInvoker() { + var listeners = new ArrayList(); + + for (var entry : this.parent.sortedPhases) { + var phase = (FilteredEvent.FilteredPhaseData) entry; + + for (var listener : phase.listenersData) { + if (listener.shouldListen(this.context)) { + listeners.add(listener.listener()); + } + } + } + + this.invoker = this.parent.invokerFactory.apply( + listeners.toArray(length -> (T[]) Array.newInstance(this.parent.type(), length)) + ); + } +} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/package-info.java b/libraries/event/src/main/java/dev/yumi/commons/event/package-info.java index 5bcb481..8a849b0 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/package-info.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/package-info.java @@ -16,6 +16,7 @@ * and events are created and managed with the help of an {@link dev.yumi.commons.event.EventManager event manager}. * * @see dev.yumi.commons.event.Event + * @see dev.yumi.commons.event.FilteredEvent * @see dev.yumi.commons.event.EventManager */ diff --git a/libraries/event/src/main/java/module-info.java b/libraries/event/src/main/java/module-info.java index 5f9ff53..f5f76e5 100644 --- a/libraries/event/src/main/java/module-info.java +++ b/libraries/event/src/main/java/module-info.java @@ -24,4 +24,4 @@ exports dev.yumi.commons.event; exports dev.yumi.commons.event.invoker; -} \ No newline at end of file +} diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java index e607bd4..85bf20d 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java @@ -41,5 +41,10 @@ public void test() { var filterEvent = events.create(FilterTestCallback.class); tester.assertCalled(1); assertSame(filterEvent, lastEvent[0]); + + tester.reset(); + var phasedEvent = events.createWithPhases(TestCallback.class, "pre", "default", "post"); + tester.assertCalled(1); + assertSame(phasedEvent, lastEvent[0]); } } diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java index 06279df..5f72988 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java @@ -9,19 +9,42 @@ package dev.yumi.commons.event.test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; public class ExecutionTester { + private int callOrder; + private boolean strictOrder = true; private int calls; - public void reset() { + public ExecutionTester useStrictOrder(boolean strictOrder) { + this.strictOrder = strictOrder; + return this; + } + + public ExecutionTester reset() { + this.callOrder = 0; + this.strictOrder = true; this.calls = 0; + + return this; } public void assertOrder(int order) { - assertEquals(order, this.calls, "Expected listener n°" + order + " to be called."); + if (this.strictOrder) { + assertEquals(order, this.callOrder, "Expected listener n°" + order + " to be called."); + this.callOrder++; + } else { + assertTrue(this.callOrder <= order, "Expected any listener before n°" + order + " to be called."); + this.callOrder = order; + } + this.calls++; } + public void skip() { + this.callOrder++; + } + public void assertCalled(int called) { assertEquals(called, this.calls, "Expected a specific amount of listener calls."); } diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java new file mode 100644 index 0000000..7c1fe93 --- /dev/null +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event.test; + +import dev.yumi.commons.collections.YumiCollections; +import dev.yumi.commons.event.EventManager; +import dev.yumi.commons.event.FilteredEvent; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +public class FilteredEventTest { + private static final EventManager EVENTS = new EventManager<>("default", Function.identity()); + + @Test + public void test() { + var tester = new ExecutionTester(); + var event = EVENTS.createFiltered(TestCallback.class, String.class); + + event.register(text -> tester.assertOrder(0)); + event.register(text -> tester.assertOrder(1)); + event.register(text -> tester.assertOrder(2)); + event.register(text -> tester.assertOrder(3), context -> context.equals("test context")); + + // Without filtering. + event.invoker().call("3"); + tester.assertCalled(3); + + tester.reset(); + + var filtered = event.forContext("test context"); + filtered.invoker().call("4"); + tester.assertCalled(4); + + tester.reset(); + + filtered = event.forContext("other context"); + filtered.invoker().call("3 again"); + tester.assertCalled(3); + } + + @Test + public void testPhases() { + var tester = new ExecutionTester(); + + record Listener(int order, Predicate selector) { + void register(String phase, ExecutionTester tester, FilteredEvent event) { + if (this.selector == null) { + event.register(phase, text -> tester.assertOrder(this.order)); + } else { + event.register(phase, text -> tester.assertOrder(this.order), this.selector); + event.register(phase, text -> tester.skip(), this.selector.negate()); + } + } + } + + record Entry(String phase, List listeners) { + Entry(String phase, Listener listener) { + this(phase, List.of(listener)); + } + } + + YumiCollections.forAllPermutations( + List.of( + new Entry("very_early", new Listener(0, null)), + new Entry("early", new Listener(1, null)), + new Entry("default", List.of( + new Listener(2, context -> context.equals("contextualized")), + new Listener(3, null) + )), + new Entry("late", new Listener(4, context -> context.equals("contextualized"))), + new Entry("very_late", List.of( + new Listener(5, null), + new Listener(6, context -> context.equals("some other context")), + new Listener(7, null) + )) + ), + entries -> { + var event = EVENTS.createFilteredWithPhases(TestCallback.class, String.class, + "very_early", "early", "default", "late", "very_late" + ); + + tester.reset(); + for (var entry : entries) { + for (var listener : entry.listeners) { + listener.register(entry.phase, tester, event); + } + } + + tester.useStrictOrder(false); + event.invoker().call("Hello world!"); + tester.assertCalled(5); + + tester.reset(); + + event.forContext("contextualized").invoker().call("Hello world!"); + tester.assertCalled(7); + + tester.reset(); + + event.forContext("some other context").invoker().call("Hello world!"); + tester.assertCalled(6); + }); + } +}