Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental - Cancel-able Menus #69

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -150,16 +148,19 @@ public boolean isShutdown()
* @param action
* The Consumer to perform an action when the condition Predicate returns {@code true}. Never null.
*
* @return Future for canceling the waiting.
* Using {@code Future#cancel(boolean)} with {@code mayInterruptIfRunning} set to {@code true} is not supported.
*
* @throws IllegalArgumentException
* One of two reasons:
* <ul>
* <li>1) Either the {@code classType}, {@code condition}, or {@code action} was {@code null}.</li>
* <li>2) The internal threadpool is shut down, meaning that no more tasks can be submitted.</li>
* </ul>
*/
public <T extends Event> void waitForEvent(Class<T> classType, Predicate<T> condition, Consumer<T> action)
public <T extends Event> Future<Void> waitForEvent(Class<T> classType, Predicate<T> condition, Consumer<T> action)
{
waitForEvent(classType, condition, action, -1, null, null);
return waitForEvent(classType, condition, action, -1, null, null);
}

/**
Expand Down Expand Up @@ -191,33 +192,36 @@ public <T extends Event> void waitForEvent(Class<T> classType, Predicate<T> cond
* The Runnable to run if the time runs out before a correct Event is thrown, or
* {@code null} if there is no action on timeout.
*
* @return Future for canceling the waiting. This will call the timeoutAction if provided.
* Using {@code Future#cancel(boolean)} with {@code mayInterruptIfRunning} set to {@code true} is not supported.
*
* @throws IllegalArgumentException
* One of two reasons:
* <ul>
* <li>1) Either the {@code classType}, {@code condition}, or {@code action} was {@code null}.</li>
* <li>2) The internal threadpool is shut down, meaning that no more tasks can be submitted.</li>
* </ul>
*/
public <T extends Event> void waitForEvent(Class<T> classType, Predicate<T> condition, Consumer<T> action,
public <T extends Event> Future<Void> waitForEvent(Class<T> classType, Predicate<T> condition, Consumer<T> action,
long timeout, TimeUnit unit, Runnable timeoutAction)
{
Checks.check(!isShutdown(), "Attempted to register a WaitingEvent while the EventWaiter's threadpool was already shut down!");
Checks.notNull(classType, "The provided class type");
Checks.notNull(condition, "The provided condition predicate");
Checks.notNull(action, "The provided action consumer");

WaitingEvent we = new WaitingEvent<>(condition, action);
Set<WaitingEvent> set = waitingEvents.computeIfAbsent(classType, c -> new HashSet<>());
set.add(we);
WaitingEvent<T> we = new WaitingEvent<>(classType, condition, action, timeoutAction);
Future<Void> future = we.getFuture();

if(timeout > 0 && unit != null)
{
threadpool.schedule(() ->
{
if(set.remove(we) && timeoutAction != null)
timeoutAction.run();
future.cancel(false);
}, timeout, unit);
}

return future;
}

@Override
Expand Down Expand Up @@ -268,26 +272,56 @@ public void shutdown()

threadpool.shutdown();
}

private class WaitingEvent<T extends Event>
{
final Class<T> classType;
final Predicate<T> condition;
final Consumer<T> action;
final Runnable cancelAction;
final CompletableFuture<Void> future = new WaitingFuture();

WaitingEvent(Predicate<T> condition, Consumer<T> action)
WaitingEvent(Class<T> classType, Predicate<T> condition, Consumer<T> action, Runnable cancelAction)
{
this.classType = classType;
this.condition = condition;
this.action = action;
this.cancelAction = cancelAction;


Set<WaitingEvent> set = waitingEvents.computeIfAbsent(classType, c -> new HashSet<>());
set.add(this);
}

boolean attempt(T event)
{
if(condition.test(event))
{
action.accept(event);
future.complete(null);
return true;
}
return false;
}

Future<Void> getFuture()
{
return future;
}

private class WaitingFuture extends CompletableFuture<Void>
{
@Override
public boolean cancel(boolean mayInterruptIfRunning)
{
if(mayInterruptIfRunning)
throw new UnsupportedOperationException("EventWaiter#waitForEvent can not be cancelled with mayInterruptIfRunning set to true");
if(isDone() || isCancelled())
return isCancelled();
if(waitingEvents.get(classType).remove(WaitingEvent.this) && cancelAction != null)
cancelAction.run();
return super.cancel(false);
}
}
}
}
13 changes: 6 additions & 7 deletions menu/src/main/java/com/jagrosh/jdautilities/menu/ButtonMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,16 @@ public class ButtonMenu extends Menu
private final String description;
private final List<String> choices;
private final Consumer<ReactionEmote> action;
private final Consumer<Message> finalAction;


ButtonMenu(EventWaiter waiter, Set<User> users, Set<Role> roles, long timeout, TimeUnit unit,
Color color, String text, String description, List<String> choices, Consumer<ReactionEmote> action, Consumer<Message> finalAction)
{
super(waiter, users, roles, timeout, unit);
super(waiter, users, roles, timeout, unit, finalAction);
this.color = color;
this.text = text;
this.description = description;
this.choices = choices;
this.action = action;
this.finalAction = finalAction;
}

/**
Expand Down Expand Up @@ -94,6 +92,7 @@ public void display(Message message)
private void initialize(RestAction<Message> ra)
{
ra.queue(m -> {
setAttachedMessage(m);
for(int i=0; i<choices.size(); i++)
{
// Get the emote to display.
Expand All @@ -113,7 +112,7 @@ private void initialize(RestAction<Message> ra)
{
// This is the last reaction added.
r.queue(v -> {
waiter.waitForEvent(MessageReactionAddEvent.class, event -> {
setCancelFuture(waiter.waitForEvent(MessageReactionAddEvent.class, event -> {
// If the message is not the same as the ButtonMenu
// currently being displayed.
if(!event.getMessageId().equals(m.getId()))
Expand All @@ -139,8 +138,8 @@ private void initialize(RestAction<Message> ra)

// Preform the specified action with the ReactionEmote
action.accept(event.getReaction().getReactionEmote());
finalAction.accept(m);
}, timeout, unit, () -> finalAction.accept(m));
finalizeMenu();
}, timeout, unit, this::finalizeMenu));
});
}
}
Expand Down
119 changes: 118 additions & 1 deletion menu/src/main/java/com/jagrosh/jdautilities/menu/Menu.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import com.jagrosh.jdautilities.commons.waiter.EventWaiter;
import net.dv8tion.jda.core.entities.*;
Expand All @@ -38,6 +40,15 @@
* the assistance of things such as {@link net.dv8tion.jda.core.entities.MessageReaction reactions},
* but the actual implementation is only limited to the events provided by Discord and handled through JDA.
*
* <p>Implementations should all use the stateful system consisting of the Constructor with finalAction parameter
* and the three methods {@link #setAttachedMessage(Message) setAttachedMessage(Message)},
* {@link #setCancelFuture(Future) setCancelFuture(Future)}
* and {@link #finalizeMenu() finalizeMenu()} (or {@link #finalizeMenu(boolean) finalizeMenu(boolean)} if needed).
* <br>By properly calling those methods as described in their docs, the Menu can be cancelled externaly by calling
* {@link #cancel() Menu#cancel()}.
* <br>This system is not mandatory, as that would break backwards compatibility with older Menu implementations,
* but it should always be used by new ones.
*
* <p>For custom implementations, readability of creating and integrating may be improved
* by the implementation of a companion builder may be helpful (see the documentation on
* {@link Menu.Builder Menu.Builder} for more info).
Expand All @@ -60,14 +71,24 @@ public abstract class Menu
protected Set<Role> roles;
protected final long timeout;
protected final TimeUnit unit;


private final Consumer<Message> finalAction;
private Future<Void> cancelFuture;
private Message attachedMessage;

protected Menu(EventWaiter waiter, Set<User> users, Set<Role> roles, long timeout, TimeUnit unit)
{
this(waiter, users, roles, timeout, unit, null);
}

protected Menu(EventWaiter waiter, Set<User> users, Set<Role> roles, long timeout, TimeUnit unit, Consumer<Message> finalAction)
{
this.waiter = waiter;
this.users = users;
this.roles = roles;
this.timeout = timeout;
this.unit = unit;
this.finalAction = finalAction;
}

/**
Expand All @@ -89,6 +110,102 @@ protected Menu(EventWaiter waiter, Set<User> users, Set<Role> roles, long timeou
*/
public abstract void display(Message message);

/**
* Returns the message, this menu was attached to.
* This is set <b>asynchronously</b> after a call to a {@code display()} or equivalent method.
* <br>The attached message will be reset, when the menu times out or is cancelled.
*
* @return The message, this menu was attached to or {@code null} if not yet attached or already timed out/cancelled.
*/
public Message getAttachedMessage()
{
return attachedMessage;
}

/**
* Cancels the menu. This stops the EventWaiter from waiting for events and behaves exactly the same way as if the menu timed out.
* That includes calling the final/cancel action if provided.
*
* @throws IllegalStateException
* If the Menu was not yet displayed, already cancelled (includes timeout or other menu ends)
* or cancel functionality is not supported by the custom implementation
*/
public void cancel()
{
if(cancelFuture == null)
throw new IllegalStateException("Menu can not be cancelled (not yet displayed, already ended or not supported by custom implementation)");
cancelFuture.cancel(false);
cancelFuture = null;
}

/**
* Internally used to set the attached message.
* Should be used by the corresponding Menu implementation as soon as the message was created/edited.
*
* @param attachedMessage
* The message, where this menu was attached to
*/
protected final void setAttachedMessage(Message attachedMessage)
{
this.attachedMessage = attachedMessage;
}

/**
* Internally used to set the cancelFuture used to cancel the menu.
* Should be used by the corresponding Menu implementation as soon as {@code EventWaiter#waitForEvent} was used (with its return value).
*
* @param cancelFuture
* The cancel Future returned from {@code EventWaiter#waitForEvent}
*/
protected final void setCancelFuture(Future<Void> cancelFuture)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned about the implied functionality through these two methods.

What if my menu doesn't need cancellation functionality? Should I still use them? Is it safe for my menu to not have cancel functionality?

This needs to be documented, and if it is required I'd appreciate it mentioned in the class level documentation for Menu.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a big block in the class-level docs of Menu describing the whole system and its use

{
this.cancelFuture = cancelFuture;
}

/**
* Returns the finalAction given via constructor.
*
* @return The finalAction given via constructor.
*/
protected final Consumer<Message> getFinalAction()
{
return finalAction;
}

/**
* Calls the final action and cleans up the attached message and cancelFuture (sets them to {@code null}).
* <br>The actual Menu implementation should should call this method whenever the waiting loop is about to exit
* and the final action should be called (e.g. as timeout action for the EventWaiter).
*
* <p>This method is a shortcut for using {@link #finalizeMenu(boolean) finalizeMenu(true)}
*
* @see #finalizeMenu(boolean)
*/
protected void finalizeMenu()
{
finalizeMenu(true);
}

/**
* Cleans up the attached message and cancelFuture (sets them to {@code null}) and optionally calls the final action.
* <br>The actual Menu implementation should should call this method whenever the waiting loop is about to exit
* and {@link #finalizeMenu() finalizeMenu()} is not applicable.
*
* @param callFinalAction
* Whether or not the final action should be called.
*
* @see #finalizeMenu()
*/
protected void finalizeMenu(boolean callFinalAction)
{
Message tmp = this.attachedMessage;
this.attachedMessage = null;
//also clean up cancelFuture if not already
this.cancelFuture = null;
if(callFinalAction && finalAction != null)
finalAction.accept(tmp);
}

/**
* Checks to see if the provided {@link net.dv8tion.jda.core.entities.User User}
* is valid to interact with this Menu.<p>
Expand Down
Loading