Skip to content

Commit

Permalink
Add more tests, fix createWithPhases, add some docs.
Browse files Browse the repository at this point in the history
  • Loading branch information
LambdAurora committed Nov 12, 2024
1 parent 6bcb821 commit f1d0a02
Show file tree
Hide file tree
Showing 8 changed files with 351 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
* }</pre>
*
* @param <I> the phase identifier type
* @param <T> the type of the invoker used to execute an event and the type of the listeners
* @param <T> the type of the listeners, and the type of the invoker used to execute an event
* @version 1.0.0
* @since 1.0.0
*/
Expand Down
159 changes: 141 additions & 18 deletions libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -171,7 +172,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function<String, I> 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}
* Must contain {@link EventManager#defaultPhaseId() the default phase identifier}
* @param <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
Expand Down Expand Up @@ -207,7 +208,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function<String, I> phas
*
* @param type the class representing the type of the invoker that is executed by the event
* @param defaultPhases the default phases of this event, in the correct order.
* Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier}
* Must contain {@link EventManager#defaultPhaseId() the default phase identifier}
* @param <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
Expand Down Expand Up @@ -242,7 +243,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function<String, I> phas
* @param type the class representing the type of the invoker that is executed by 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}
* Must contain {@link EventManager#defaultPhaseId() the default phase identifier}
* @param <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
Expand All @@ -258,28 +259,130 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function<String, I> phas
@NotNull Function<T[], T> implementation,
@NotNull I... defaultPhases
) {
this.ensureContainsDefaultPhase(defaultPhases);
YumiAssertions.ensureNoDuplicates(defaultPhases, id -> new IllegalArgumentException("Duplicate event phase: " + id));

var event = this.create(type, implementation);
return this.createWithPhases(() -> new Event<>(type, this.defaultPhaseId, implementation), defaultPhases);
}

for (int i = 1; i < defaultPhases.length; ++i) {
event.addPhaseOrdering(defaultPhases[i - 1], defaultPhases[i]);
}
public <T, C> @NotNull FilteredEvent<I, T, C> createFiltered(@NotNull Class<? super T> type, @NotNull Class<? super C> contextType) {
return this.createFiltered(type, contextType, new DefaultInvokerFactory<>(type));
}

public <T, C> @NotNull FilteredEvent<I, T, C> createFiltered(
@NotNull Class<? super T> type,
@NotNull Class<? super C> contextType,
@NotNull Function<T[], T> implementation
) {
var event = new FilteredEvent<I, T, C>(type, this.defaultPhaseId, implementation);
this.creationEvent.invoker().onEventCreation(this, event);

return event;
}

public <T, C> @NotNull FilteredEvent<I, T, C> createFiltered(@NotNull Class<? super T> type) {
return this.createFiltered(type, new DefaultInvokerFactory<>(type));
/**
* Create a new instance of {@link Event} 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Refer to {@link Event#addPhaseOrdering} for an explanation of event phases.
*
* @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 <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
* @see #create(InvokerFactory)
* @see #create(Class, Function)
* @see #create(Class, Object, Function)
* @see #createWithPhases(Class, Comparable[])
* @see #createWithPhases(InvokerFactory, Comparable[])
*/
@SuppressWarnings("unchecked")
public <T, C> @NotNull FilteredEvent<I, T, C> createFilteredWithPhases(
@NotNull Class<C> contextType,
@NotNull InvokerFactory<T> invokerFactory,
@NotNull I... defaultPhases
) {
return this.createFilteredWithPhases(invokerFactory.type(), contextType, invokerFactory, defaultPhases);
}

public <T, C> @NotNull FilteredEvent<I, T, C> createFiltered(@NotNull Class<? super T> type, @NotNull Function<T[], T> implementation) {
var event = new FilteredEvent<I, T, C>(type, this.defaultPhaseId, implementation);
this.creationEvent.invoker().onEventCreation(this, event);
return event;
/**
* Create a new instance of {@link Event} 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Refer to {@link Event#addPhaseOrdering} for an explanation of event phases.
* <p>
* 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 defaultPhases the default phases of this event, in the correct order.
* Must contain {@link EventManager#defaultPhaseId() the default phase identifier}
* @param <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
* @see #create(InvokerFactory)
* @see #create(Class, Function)
* @see #create(Class, Object, Function)
* @see #createWithPhases(InvokerFactory, Comparable[])
* @see #createWithPhases(Class, Function, Comparable[])
*/
@SuppressWarnings("unchecked")
public <T, C> @NotNull FilteredEvent<I, T, C> createFilteredWithPhases(
@NotNull Class<? super T> type,
@NotNull Class<C> contextType,
@NotNull I... defaultPhases
) {
return this.createFilteredWithPhases(type, contextType, new DefaultInvokerFactory<>(type), defaultPhases);
}

/**
* Create a new instance of {@link Event} 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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 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 <T> the type of the invoker executed by the event
* @return a new event instance
* @see #create(Class)
* @see #create(InvokerFactory)
* @see #create(Class, Function)
* @see #create(Class, Object, Function)
* @see #createWithPhases(Class, Comparable[])
* @see #createWithPhases(InvokerFactory, Comparable[])
*/
@SafeVarargs
public final <T, C> @NotNull FilteredEvent<I, T, C> createFilteredWithPhases(
@NotNull Class<? super T> type,
@NotNull Class<C> contextType,
@NotNull Function<T[], T> implementation,
@NotNull I... defaultPhases
) {
return this.createWithPhases(() -> new FilteredEvent<>(type, this.defaultPhaseId, implementation), defaultPhases);
}

/**
Expand Down Expand Up @@ -327,7 +430,7 @@ public final void listenAll(Object listener, Event<I, ?>... 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;
}

Expand All @@ -339,6 +442,26 @@ public final void listenAll(Object listener, Event<I, ?>... events) {
return this.creationEvent;
}

/* Implementation */

private <E extends Event<I, ?>> E createWithPhases(
Supplier<E> 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<Class<?>, I> getListenedPhases(Class<?> listenerClass) {
var map = new HashMap<Class<?>, I>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,96 @@
import java.util.function.Function;
import java.util.function.Predicate;

/**
* Represents an {@linkplain Event event} which can filter its listeners given an invocation context.
* <p>
* This type of event will respect the same rules and assumptions as regular events.
*
* <h2>Example: Registering filtered listeners</h2>
* <p>
* 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}.
*
* <pre>{@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<String> 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<String, Example> 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
* }
* }
* }</pre>
*
* <h2>Example: Executing a filtered event</h2>
* <p>
* 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:
*
* <pre>{@code
* InvokerSubset subset = EXAMPLE.forContext(new EventContext("test"));
*
* // Invoke the listeners relevant to the given context.
* subset.invoker().doSomething();
* }</pre>
* <p>
* 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 <I> the phase identifier type
* @param <T> the type of the listeners, and the type of the invoker used to execute an event
* @param <C> 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<I extends Comparable<? super I>, T, C> extends Event<I, T> {
/**
* Reference queue for cleared invoker subsets.
*/
private final ReferenceQueue<InvokerSubset<I, T, C>> queue = new ReferenceQueue<>();
/**
* A cache of currently alive invoker subsets.
*/
private final Set<WeakReference<InvokerSubset<I, T, C>>> subsets = new HashSet<>();

FilteredEvent(@NotNull Class<? super T> type, @NotNull I defaultPhaseId, @NotNull Function<T[], T> invokerFactory) {
Expand Down Expand Up @@ -63,12 +148,20 @@ public void register(@NotNull I phaseIdentifier, @NotNull T listener, @NotNull P
this.lock.lock();
try {
this.getOrCreatePhase(phaseIdentifier, true).addListener(new Listener<>(listener, filter));
this.rebuildInvoker(this.listeners.length + 1);
this.rebuildInvoker(this.listeners.length);
} finally {
this.lock.unlock();
}
}

/**
* Creates an invoker for a subset of listeners that matches the given context.
* <p>
* 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 InvokerSubset<I, T, C> forContext(C context) {
var subset = new InvokerSubset<>(this, context);

Expand All @@ -83,7 +176,10 @@ public InvokerSubset<I, T, C> forContext(C context) {
return subset;
}

@SuppressWarnings("unchecked")
// 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);
Expand Down Expand Up @@ -116,7 +212,6 @@ void rebuildInvoker(int newLength) {
}
}

@SuppressWarnings("unchecked")
private void purge() {
for (var ref = this.queue.poll(); ref != null; ref = this.queue.poll()) {
//noinspection SuspiciousMethodCalls
Expand Down Expand Up @@ -147,7 +242,21 @@ void addListener(@NotNull Listener<T, C> 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 <T> the type of listener
* @param <C> the type of filtering context
*/
@ApiStatus.Internal
record Listener<T, C>(T listener, @Nullable Predicate<C> 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);
}
Expand Down
Loading

0 comments on commit f1d0a02

Please sign in to comment.