/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.wizard;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.intellij.ide.wizard.Step;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.Result;
import com.intellij.openapi.command.UndoConfirmationPolicy;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.wm.IdeFocusManager;
import com.intellij.psi.PsiFile;
import com.intellij.util.ui.update.MergingUpdateQueue;
import com.intellij.util.ui.update.Update;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.util.ArrayList;
import java.util.Map;
import java.util.Set;
import static com.android.tools.idea.wizard.ScopedStateStore.Key;
/**
* DynamicWizard is an evolution of {@link TemplateWizard} that seeks to provide a flexible base for
* implemented GUI wizards that may involve multiple steps and branches.
*
* A DynamicWizard contains a series of {@link DynamicWizardPath}s, in the order that the user is expected
* to traverse through the wizard. Paths may declare themselves to be visible or invisible (in which case they
* will be skipped by the wizard), depending on the
* state of the wizard. Each path contains a series of {@link DynamicWizardStep}s, which also may be visible or invisible, depending
* on the state of the wizard. The DynamicWizard class is responsible for providing the GUI frame for the wizard and
* maintaining the state of the wizard buttons. Each path and step is responsible for its own validation and visibility. Each step must
* provide a {@link JComponent} that serves as the user interface for that step. Each step also provides a title string and optionally
* provides an {@link Icon} to be displayed on the left hand side of the wizard pane.
*
*
*/
public abstract class DynamicWizard implements ScopedStateStore.ScopedStoreListener {
Logger LOG = Logger.getInstance(DynamicWizard.class);
// A queue of updates used to throttle the update() function.
private final MergingUpdateQueue myUpdateQueue;
// Used by update() to ensure that multiple updates are not invoked simultaneously.
private boolean myUpdateInProgress;
// A reference to the project context in which this wizard was invoked.
@Nullable private Project myProject;
// A reference to the module context in which this wizard was invoked.
@Nullable private Module myModule;
// Wizard "chrome"
@NotNull protected final DynamicWizardHost myHost;
// The name of this wizard for display to the user
protected String myName;
// List of the paths that this wizard contains. Paths can be optional or required.
protected ArrayList<AndroidStudioWizardPath> myPaths = Lists.newArrayList();
// The current path
protected AndroidStudioWizardPath myCurrentPath;
// An iterator to keep track of the user's progress through the paths.
protected PathIterator myPathListIterator = new PathIterator(myPaths);
private boolean myIsInitialized = false;
private ScopedStateStore myState;
private JPanel myContentPanel = new JPanel(new CardLayout());
private Map<JComponent, String> myComponentToIdMap = Maps.newHashMap();
public DynamicWizard(@Nullable Project project, @Nullable Module module, @NotNull String name) {
this(project, module, name, new DialogWrapperHost(project));
}
public DynamicWizard(@Nullable Project project, @Nullable Module module, @NotNull String name, @NotNull DynamicWizardHost host) {
myHost = host;
myProject = project;
myModule = module;
myName = name;
if (ApplicationManager.getApplication().isUnitTestMode()) {
myUpdateQueue = null;
} else {
myUpdateQueue = new MergingUpdateQueue("wizard", 100, true, null, myHost.getDisposable(), null, false);
}
myState = new ScopedStateStore(ScopedStateStore.Scope.WIZARD, null, this);
}
public void init() {
myHost.init(this);
myIsInitialized = true;
if (myCurrentPath != null) {
myCurrentPath.onPathStarted(true);
showStep(myCurrentPath.getCurrentStep());
myCurrentPath.updateCurrentStep();
}
}
/**
* Call update with rate throttling
*/
@Override
public <T> void invokeUpdate(@Nullable Key<T> changedKey) {
if (myUpdateQueue != null) {
myUpdateQueue.cancelAllUpdates();
myUpdateQueue.queue(new Update("update") {
@Override
public void run() {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
update();
}
});
}
});
} else {
// If we're not running in a context, just update immediately
update();
}
}
/**
* Updating: Whenever a path's update method is called with a WIZARD scope,
* it will invoke the parent Wizard's update method. This update method is rate throttled.
*/
/**
* Call the update steps in order. Will not fire if an update is already in progress.
*/
private void update() {
if (!myUpdateInProgress) {
myUpdateInProgress = true;
deriveValues(myState.getRecentUpdates());
myState.clearRecentUpdates();
myUpdateInProgress = false;
}
}
/**
* Takes the list of changed variables and uses them to recalculate any variables
* which may depend on those changed values.
* @param modified map of the keys of the changed objects in the state store to their scopes.
*/
public void deriveValues(Set<Key> modified) {
}
/**
* Declare any finishing actions that will take place at the completion of the wizard.
* This function is called inside of a {@link WriteCommandAction}.
*/
public abstract void performFinishingActions();
/**
* Get the project context which this wizard is operating under.
* If the this wizard is a global one, the function returns null.
*/
@Nullable
protected final Project getProject() {
return myProject;
}
/**
* Get the module context which this wizard is operating under.
* If the this wizard is a global one or project-scoped, the function returns null.
*/
@Nullable
protected final Module getModule() {
return myModule;
}
/**
* Converts the given text to an HTML message if necessary, and then displays it to the user.
* @param errorMessage the message to display
*/
public final void setErrorHtml(String errorMessage) {
if (myCurrentPath != null) {
myCurrentPath.setErrorHtml(errorMessage);
}
}
/**
* Update the buttons for the wizard
* @param canGoPrev whether the previous button is enabled
* @param canGoNext whether the next button is enabled
* @param canFinishCurrentPath if this is set to true and the current path is the last non-optional path, the canFinish
* button will be enabled.
*/
public final void updateButtons(boolean canGoPrev, boolean canGoNext, boolean canFinishCurrentPath) {
if (!myIsInitialized) {
// Buttons were not yet created
return;
}
myHost.updateButtons(canGoPrev && hasPrevious(), canGoNext && hasNext(), canFinishCurrentPath && canFinish());
}
/**
* Add the given path to the end of this wizard.
*/
protected final void addPath(@NotNull AndroidStudioWizardPath path) {
myPaths.add(path);
path.attachToWizard(this);
// If this is the first visible path, select it
if (myCurrentPath == null && path.isPathVisible()) {
myCurrentPath = path;
}
// Rebuild the iterator to avoid concurrent modification exceptions
myPathListIterator = new PathIterator(myPaths, myCurrentPath);
}
/**
* @return the total number of visible steps in this wizard.
*/
public final int getVisibleStepCount() {
int sum = 0;
for (AndroidStudioWizardPath path : myPaths) {
sum += path.getVisibleStepCount();
}
return sum;
}
protected void showStep(@NotNull Step step) {
JComponent component = step.getComponent();
Icon icon = step.getIcon();
myHost.setIcon(icon);
// Store a reference to this component.
String id = myComponentToIdMap.get(component);
if (id == null) {
id = String.valueOf(myComponentToIdMap.size());
myComponentToIdMap.put(component, id);
myContentPanel.add(component, id);
}
((CardLayout)myContentPanel.getLayout()).show(myContentPanel, id);
JComponent focusedComponent = step.getPreferredFocusedComponent();
if (focusedComponent != null) {
IdeFocusManager.findInstanceByComponent(focusedComponent).requestFocus(focusedComponent, false);
}
}
/**
* @return true if the wizard can advance to the next step. Returns false if there is an error
* on the current step or if there are no more steps. Subclasses should rarely need to override
* this method.
*/
protected boolean canGoNext() {
return myCurrentPath != null && myCurrentPath.canGoNext();
}
/**
* @return true if the wizard can go back to the previous step. Returns false if there is an error
* on the current step or if there are no more steps prior to the current one.
* Subclasses should rarely need to override this method.
*/
protected boolean canGoPrevious() {
return myCurrentPath != null && myCurrentPath.canGoPrevious();
}
/**
* @return true if the wizard has additional visible steps. Subclasses should rarely need to override
* this method.
*/
protected boolean hasNext() {
return myCurrentPath != null && myCurrentPath.hasNext() || myPathListIterator.hasNext();
}
/**
* @return true if the wizard has previous visible steps
* Subclasses should rarely need to override this method.
*/
protected boolean hasPrevious() {
return myCurrentPath != null && myCurrentPath.hasPrevious() || myPathListIterator.hasPrevious();
}
/**
* @return true if the wizard is in a state in which it can finish. This is defined as being done with the current
* path and having no required paths remaining. Subclasses should rarely need to override
* this method.
*/
protected boolean canFinish() {
if (!myPathListIterator.hasNext() && (myCurrentPath == null || !myCurrentPath.hasNext())) {
return true;
} else if (myCurrentPath != null && myCurrentPath.hasNext()) {
return false;
}
boolean canFinish = true;
PathIterator remainingPaths = myPathListIterator.getFreshCopy();
while(canFinish && remainingPaths.hasNext()) {
canFinish = !remainingPaths.next().isPathRequired();
}
return canFinish;
}
/**
* @return true iff the current step is the last one in the wizard (required or optional)
*/
protected final boolean isLastStep() {
if (myCurrentPath != null) {
return !myPathListIterator.hasNext() && !myCurrentPath.hasNext();
} else {
return !myPathListIterator.hasNext();
}
}
/**
* Commit the current step and move to the next step. Subclasses should rarely need to override
* this method.
*/
public final void doNextAction() {
assert myCurrentPath != null;
if (!myCurrentPath.canGoNext()) {
myHost.shakeWindow();
return;
}
Step newStep;
if (!myCurrentPath.hasNext() && myPathListIterator.hasNext()) {
if (!myCurrentPath.readyToLeavePath()) {
myHost.shakeWindow();
return;
}
myCurrentPath = myPathListIterator.next();
myCurrentPath.onPathStarted(true /* fromBeginning */);
newStep = myCurrentPath.getCurrentStep();
} else if (myCurrentPath.hasNext()) {
newStep = myCurrentPath.next();
} else {
doFinishAction();
return;
}
if (newStep != null) {
showStep(newStep);
}
}
/**
* Find and go to the previous step. Subclasses should rarely need to override
* this method.
*/
public final void doPreviousAction() {
assert myCurrentPath != null;
if (!myCurrentPath.canGoPrevious()) {
myHost.shakeWindow();
return;
}
Step newStep;
if ((myCurrentPath == null || !myCurrentPath.hasPrevious()) && myPathListIterator.hasPrevious()) {
myCurrentPath = myPathListIterator.previous();
myCurrentPath.onPathStarted(false /* fromBeginning */);
newStep = myCurrentPath.getCurrentStep();
} else if (myCurrentPath.hasPrevious()) {
newStep = myCurrentPath.previous();
} else {
myHost.close(true);
return;
}
if (newStep != null) {
showStep(newStep);
}
}
/**
* Complete the wizard, doing any finishing actions that have been queued up during the wizard flow
* inside a write action and a command. Subclasses should rarely need to override
* this method.
*/
public final void doFinishAction() {
if (myCurrentPath != null && !myCurrentPath.readyToLeavePath()) {
myHost.shakeWindow();
return;
}
myHost.close(false);
new WriteCommandAction<Void>(getProject(), getWizardActionDescription(), (PsiFile[])null) {
@Override
protected void run(@NotNull Result<Void> result) throws Throwable {
for (AndroidStudioWizardPath path : myPaths) {
if (path.isPathVisible()) {
path.performFinishingActions();
}
}
performFinishingActions();
}
@Override
protected UndoConfirmationPolicy getUndoConfirmationPolicy() {
return DynamicWizard.this.getUndoConfirmationPolicy();
}
}.execute();
}
protected UndoConfirmationPolicy getUndoConfirmationPolicy() {
return UndoConfirmationPolicy.DEFAULT;
}
@Nullable
public final JComponent getPreferredFocusedComponent() {
Step currentStep = myCurrentPath.getCurrentStep();
if (currentStep != null) {
return currentStep.getPreferredFocusedComponent();
}
else {
return null;
}
}
protected abstract String getWizardActionDescription();
/**
* @return the scoped state store associate with this wizard as a whole
*/
public final ScopedStateStore getState() {
return myState;
}
public final void show() {
myHost.show();
}
@NotNull
public Disposable getDisposable() {
return myHost.getDisposable();
}
public boolean showAndGet() {
return myHost.showAndGet();
}
public final Component getContentPane() {
return myContentPanel;
}
@Nullable
public String getHelpId() {
return null;
}
public void setTitle(String title) {
myHost.setTitle(title);
}
/**
* Returns true if a step with the given name exists in this wizard's current configuration.
* If visibleOnly is set to true, only visible steps (that are part of visible paths) will
* be considered.
*/
public boolean containsStep(@NotNull String stepName, boolean visibleOnly) {
for (AndroidStudioWizardPath path : myPaths) {
if (visibleOnly && !path.isPathVisible()) {
continue;
}
if (path.containsStep(stepName, visibleOnly)) {
return true;
}
}
return false;
}
/**
* Navigates this wizard to the step with the given name if it exists. If not, this function
* is a no-op. If the requireVisible parameter is set to true, then only currently visible steps (which
* are part of currently visible paths) will be considered.
*/
public void navigateToNamedStep(@NotNull String stepName, boolean requireVisible) {
for (AndroidStudioWizardPath path : myPaths) {
if ((!requireVisible || path.isPathVisible()) && path.containsStep(stepName, requireVisible)) {
myCurrentPath = path;
myPathListIterator.myCurrentIndex = myPathListIterator.myList.indexOf(myCurrentPath);
myCurrentPath.navigateToNamedStep(stepName, requireVisible);
showStep(myCurrentPath.getCurrentStep());
return;
}
}
}
protected static class PathIterator {
private int myCurrentIndex;
private ArrayList<AndroidStudioWizardPath> myList;
public PathIterator(ArrayList<AndroidStudioWizardPath> list) {
myList = list;
myCurrentIndex = 0;
}
public PathIterator(ArrayList<AndroidStudioWizardPath> list, AndroidStudioWizardPath currentLocation) {
this(list);
int index = myList.indexOf(currentLocation);
if (currentLocation != null && index != -1) {
myCurrentIndex = index;
}
}
/**
* @return a copy of this iterator
*/
public PathIterator getFreshCopy() {
PathIterator toReturn = new PathIterator(myList);
toReturn.myCurrentIndex = myCurrentIndex;
return toReturn;
}
/**
* @return true iff there are more visible paths with steps following the current location
*/
public boolean hasNext() {
if (myCurrentIndex >= myList.size() - 1) {
return false;
}
for (int i = myCurrentIndex + 1; i < myList.size(); i++) {
AndroidStudioWizardPath path = myList.get(i);
if (path.isPathVisible() && path.getVisibleStepCount() > 0) {
return true;
}
}
return false;
}
/**
* @return true iff this path has more visible steps previous to its current step
*/
public boolean hasPrevious() {
if (myCurrentIndex <= 0) {
return false;
}
for (int i = myCurrentIndex - 1; i >= 0; i--) {
if (myList.get(i).isPathVisible()) {
return true;
}
}
return false;
}
/**
* Advance to the next visible path and return it, or null if there are no following visible paths
* @return the next path
*/
@Nullable
public AndroidStudioWizardPath next() {
do {
myCurrentIndex++;
} while(myCurrentIndex < myList.size() && !myList.get(myCurrentIndex).isPathVisible());
if (myCurrentIndex < myList.size()) {
return myList.get(myCurrentIndex);
} else {
return null;
}
}
/**
* Go back to the last visible path and return it, or null if there are no previous visible paths
*/
@Nullable
public AndroidStudioWizardPath previous() {
do {
myCurrentIndex--;
} while(myCurrentIndex >= 0 && !myList.get(myCurrentIndex).isPathVisible());
if (myCurrentIndex >= 0) {
return myList.get(myCurrentIndex);
} else {
return null;
}
}
}
}