/* * Copyright (C) 2006 Sun Microsystems, Inc. All rights reserved. * Copyright (C) 2010 Peransin Nicolas. All rights reserved. * Use is subject to license terms. */ package org.mypsycho.swing.app.beans; import java.awt.Component; import java.awt.event.ActionEvent; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.EventObject; import java.util.List; import java.util.Locale; import org.mypsycho.beans.Inject; import org.mypsycho.beans.Injectable; import org.mypsycho.beans.InjectionContext; import org.mypsycho.beans.InjectionStack; import org.mypsycho.swing.app.Action; import org.mypsycho.swing.app.Application; import org.mypsycho.swing.app.ApplicationContext; import org.mypsycho.swing.app.ResourceManager; import org.mypsycho.swing.app.task.DefaultInputBlocker; import org.mypsycho.swing.app.task.Task; import org.mypsycho.swing.app.utils.SwingHelper; /** * The {@link javax.swing.Action} class used to implement the * <tt>@Action</tt> annotation. This class is typically not * instantiated directly, it's created as a side effect of constructing * an <tt>ApplicationActionMap</tt>: * <pre> * public class MyActions { * @Action public void anAction() { } // an @Action named "anAction" * } * ApplicationContext ac = ApplicationContext.getInstance(); * ActionMap actionMap = ac.getActionMap(new MyActions()); * myButton.setAction(actionMap.get("anAction")); * </pre> * * <p> * When an ApplicationAction is constructed, it initializes all of its * properties from the specified <tt>ResourceMap</tt>. Resource names * must match the {@code @Action's} name, which is the name of the * corresponding method, or the value of the optional {@code @Action} name * parameter. To initialize the text and shortDescription properties * of the action named <tt>"anAction"</tt> in the previous example, one * would define two resources: * <pre> * anAction.Action.text = Button/Menu/etc label text for anAction * anAction.Action.shortDescription = Tooltip text for anAction * </pre> * * <p> * A complete description of the mapping between resources and Action * properties can be found in the ApplicationAction {@link * #ApplicationAction constructor} documentation. * * <p> * An ApplicationAction's <tt>enabled</tt> and <tt>selected</tt> * properties can be delegated to boolean properties of the * Actions class, by specifying the corresponding property names. * This can be done with the {@code @Action} annotation, e.g.: * <pre> * public class MyActions { * @Action(enabledProperty = "anActionEnabled") * public void anAction() { } * public boolean isAnActionEnabled() { * // will fire PropertyChange when anActionEnabled changes * return anActionEnabled; * } * } * </pre> * If the MyActions class supports PropertyChange events, then then * ApplicationAction will track the state of the specified property * ("anActionEnabled" in this case) with a PropertyChangeListener. * * <p> * ApplicationActions can automatically <tt>block</tt> the GUI while the * <tt>actionPerformed</tt> method is running, depending on the value of * block annotation parameter. For example, if the value of block is * <tt>Task.BlockingScope.ACTION</tt>, then the action will be disabled while * the actionPerformed method runs. * * <p> * An ApplicationAction can have a <tt>proxy</tt> Action, i.e. * another Action that provides the <tt>actionPerformed</tt> method, * the enabled/selected properties, and values for the Action's long * and short descriptions. If the proxy property is set, this * ApplicationAction tracks all of the aforementioned properties, and * the <tt>actionPerformed</tt> method just calls the proxy's * <tt>actionPerformed</tt> method. If a <tt>proxySource</tt> is * specified, then it becomes the source of the ActionEvent that's * passed to the proxy <tt>actionPerformed</tt> method. Proxy action * dispatching is as simple as this: * <pre> * public void actionPerformed(ActionEvent actionEvent) { * javax.swing.Action proxy = getProxy(); * if (proxy != null) { * actionEvent.setSource(getProxySource()); * proxy.actionPerformed(actionEvent); * } * // .... * } * </pre> * * * @author Hans Muller (Hans.Muller@Sun.COM) * @see ApplicationContext#getActionMap(Object) * @see ResourceMap */ @SuppressWarnings("serial") // Mnemonic and displayedMnemonicIndex are overriden if "text" property is set afterward. @Inject(order={ "text" }, deferred=ApplicationAction.TASK_PROP) public class ApplicationAction extends AbstractTypedAction implements Injectable { public static final String METHOD_SEPARATOR = "#"; public static final String TASK_PROP = "task"; private final Application app; private final Object actionBean; private final Method actionMethod; // The @Action method private String enabledProperty = null; // maybe an expression private boolean enabledWrittable = true; private String selectedProperty = null; // maybe an expression private boolean selectedWrittable = true; private Task.BlockingScope block = Task.BlockingScope.ACTION; private InjectionStack context = new InjectionStack(this); private final Locale locale; /** * Construct an <tt>ApplicationAction</tt> that implements an <tt>@Action</tt>. * * <p> * If a {@code ResourceMap} is provided, then all of the * {@link javax.swing.Action Action} properties are initialized * with the values of resources whose key begins with {@code baseName}. * ResourceMap keys are created by appending an @Action resource * name, like "Action.shortDescription" to the @Action's baseName * For example, Given an @Action defined like this: * <pre> * @Action void actionBaseName() { } * </pre> * <p> * Then the shortDescription resource key would be * <code>actionBaseName.Action.shortDescription</code>, as in: * <pre> * actionBaseName.Action.shortDescription = Do perform some action * </pre> * * <p> * The complete set of @Action resources is: * <pre> * name * icon * text * shortDescription * longDescription * smallIcon * largeIcon * command * accelerator * mnemonic * displayedMnemonicIndex * </pre> * * <p> * A few the resources are handled specially: * <ul> * <li><tt>text</tt><br> * Used to initialize the Action properties with keys * <tt>Action.NAME</tt>, <tt>Action.MNEMONIC_KEY</tt> and * <tt>Action.DISPLAYED_MNEMONIC_INDEX</tt>. * If the resources's value contains an "&" or an "_" it's * assumed to mark the following character as the mnemonic. * If Action.mnemonic/Action.displayedMnemonic resources are * also defined (an odd case), they'll override the mnemonic * specfied with the Action.text marker character. * * <li><tt>Action.icon</tt><br> * Used to initialize both ACTION.SMALL_ICON,LARGE_ICON. If * Action.smallIcon or Action.largeIcon resources are also defined * they'll override the value defined for Action.icon. * * <li><tt>Action.displayedMnemonicIndexKey</tt><br> * The corresponding javax.swing.Action constant is only defined in Java SE 6. * We'll set the Action property in Java SE 5 too. * </ul> * * @param appAM the ApplicationActionMap this action is being constructed for. * @param resourceMap initial Action properties are loaded from this ResourceMap. * @param baseName the name of the @Action * @param actionMethod unless a proxy is specified, actionPerformed calls this method. * @param enabledProperty name of the enabled property. * @param selectedProperty name of the selected property. * @param block how much of the GUI to block while this action executes. * * @see #getName * @see ApplicationActionMap#getActionsClass * @see ApplicationActionMap#getActionsObject */ public ApplicationAction(Application pApp, String def, Object src) throws NoSuchMethodException, IllegalAccessException { this(pApp, def, src, null); } public ApplicationAction(Application pApp, String def, Object src, Locale locale) throws NoSuchMethodException, IllegalAccessException { SwingHelper.assertNotNull("application", pApp); SwingHelper.assertNotNull("definition", def); SwingHelper.assertNotNull("source", src); app = pApp; this.locale = (locale != null) ? locale : app.getLocale(); boolean distant = false; if (def.isEmpty()) { // No real action, only a container for text, tooltip, etc. actionMethod = null; actionBean = null; } else { int methodIndex = def.lastIndexOf(METHOD_SEPARATOR); String methodName = def; distant = (methodIndex > 0); if (!distant) { // no path, action : <methodName> if (methodIndex == 0) { // if start by SEPARATOR, ignored methodName = def.substring(METHOD_SEPARATOR.length()); } actionBean = src; } else { // action : <beanPath>#<methodName> methodName = def.substring(methodIndex + METHOD_SEPARATOR.length()); try { String path = def.substring(0, methodIndex); actionBean = getResourceManager().getProperty(src, path); } catch (InvocationTargetException e) { NoSuchMethodException reThrown = new NoSuchMethodException(e.getMessage()); reThrown.initCause(e.getTargetException()); throw reThrown; } SwingHelper.assertNotNull("bean", actionBean); } // Find action method actionMethod = findActionMethod(actionBean.getClass(), methodName); initMethodDetail(actionMethod.getAnnotation(Action.class), locale, distant); } // Listener try { if ((enabledProperty != null) || (selectedProperty != null)) { Class<?> actionsClass = actionBean.getClass(); Method m = actionsClass.getMethod("addPropertyChangeListener", PropertyChangeListener.class); m.invoke(actionBean, new ProxyPCL()); } } catch (Exception e) { // No CallBack for properties } } /** * Find the action method of the provided class. * <p> * Returned method must have the required name and supported arguments * type. * If several methods are found, the one with <code>@Action<code> * annotation is choosen. * </p> * * @param methodName * @return the foud method * @throws IllegalArgumentException if no method or too many methods are found */ private Method findActionMethod(Class<?> type, String methodName) { List<Method> methods = new ArrayList<Method>(); Action detail = null; for (Method method : type.getDeclaredMethods()) { if (!isMethodValid(method, methodName)) { continue; } Action annotation = method.getAnnotation(Action.class); if (detail == null) { if (annotation != null) { methods.clear(); detail = annotation; } methods.add(method); } else if (annotation != null) { methods.add(method); } } if (methods.isEmpty()) { if (Object.class.equals(type.getSuperclass())) { throw new IllegalArgumentException("No method '" + methodName + "' for class " + actionBean.getClass().getName()); } return findActionMethod(type.getSuperclass(), methodName); } else if (methods.size() > 1) { throw new IllegalArgumentException("Too many method '" + methodName + "' for class " + actionBean.getClass().getName()); } return methods.get(0); } private void initMethodDetail(Action detail, Locale locale, boolean distant) { if (detail == null) { return; } enabledProperty = detail.enabled(); selectedProperty = detail.selected(); block = detail.block(); /* If enabledProperty is specified, lookup up the is/set methods and * verify that the former exists. */ if ((enabledProperty != null) && !enabledProperty.isEmpty()) { boolean enabled = isEnabled(); // If an exception is raise, the action is invalid super.setEnabled(enabled); try { setEnabled(enabled); // } catch (Exception e) { enabledWrittable = false; } } else { enabledProperty = null; enabledWrittable = false; } /* If selectedProperty is specified, lookup up the is/set methods and * verify that the former exists. */ if ((selectedProperty != null) && !selectedProperty.isEmpty()) { boolean selected = isSelected(); // If an exception is raise, the action is invalid super.putValue(SELECTED_KEY, selected); try { setSelected(selected); } catch (Exception e) { selectedWrittable = false; } } else { selectedProperty = null; selectedWrittable = false; } if (distant && (detail.name() != null) && !detail.name().isEmpty()) { getResourceManager().inject(actionBean, locale, detail.name(), this); } } public final ResourceManager getResourceManager() { return app.getContext().getResourceManager(); } protected boolean isMethodValid(Method method, String name) { if (!method.getName().equals(name) || !Modifier.isPublic(method.getModifiers())) { return false; } for (Class<?> argType : method.getParameterTypes()) { if (!isMethodArgumentValid(argType)) { return false; } } return true; } protected boolean isMethodArgumentValid(Class<?> pType) { // Source Component ? accessible by the action event if (pType.isAssignableFrom(Component.class)) { return true; } if (pType.isAssignableFrom(ActionEvent.class) && EventObject.class.isAssignableFrom(pType)) { return true; } if (javax.swing.Action.class.isAssignableFrom(pType) && pType.isInstance(this)) { return true; } if (InjectionStack.class.equals(pType)) { return true; } if (ApplicationContext.class.isAssignableFrom(pType) && pType.isInstance(app.getContext())) { return true; } if (Application.class.isAssignableFrom(pType) && pType.isInstance(app)) { return true; } return false; } public void initResources(InjectionContext context) { this.context.addContext(context); // last context or context stack ? } /* This PCL is added to the proxy action, i.e. getProxy(). We * track the following properties of the proxy action we're bound to: * enabled, selected, longDescription, shortDescription. We only * mirror the description properties if they're non-null. */ private class ProxyPCL implements PropertyChangeListener { public void propertyChange(PropertyChangeEvent e) { String propertyName = e.getPropertyName(); if ((enabledProperty != null) && enabledProperty.equals(propertyName)) { ApplicationAction.super.putValue(ENABLED_KEY, e.getNewValue()); } if ((selectedProperty != null) && selectedProperty.equals(propertyName)) { ApplicationAction.super.putValue(SELECTED_KEY, e.getNewValue()); } } } /** * * Provides parameter values to @Action methods. By default, parameter * values are selected based exclusively on their type: * <table border=1> * <tr> * <th>Parameter Type</th> * <th>Parameter Value</th> * </tr> * <tr> * <td><tt>ActionEvent</tt></td> * <td><tt>actionEvent</tt></td> * </tr> * <tr> * <td><tt>javax.swing.Action</tt></td> * <td>this <tt>ApplicationAction</tt> object</td> * </tr> * <tr> * <td><tt>ActionMap</tt></td> * <td>the <tt>ActionMap</tt> that contains this <tt>Action</tt></td> * </tr> * <tr> * <td><tt>ResourceMap</tt></td> * <td>the <tt>ResourceMap</tt> of the the <tt>ActionMap</tt> that contains this <tt>Action</tt></td> * </tr> * <tr> * <td><tt>ApplicationContext</tt></td> * <td>the value of <tt>ApplicationContext.getInstance()</tt></td> * </tr> * </table> * * <p> * ApplicationAction subclasses may also select values based on * the value of the <tt>Action.Parameter</tt> annotation, which is * passed along as the <tt>pKey</tt> argument to this method: * <pre> * @Action public void doAction(@Action.Parameter("myKey") String myParameter) { * // The value of myParameter is computed by: * // getActionArgument(String.class, "myKey", actionEvent) * } * </pre> * * <p> * If <tt>pType</tt> and <tt>pKey</tt> aren't recognized, this method * calls {@link #actionFailed} with an IllegalArgumentException. * * * @param pType parameter type * @param pKey the value of the @Action.Parameter annotation * @param actionEvent the ActionEvent that trigged this Action */ protected Object getActionArgument(Class<?> pType, ActionEvent actionEvent) { if (pType.isInstance(actionEvent.getSource())) { return actionEvent.getSource(); } if (pType.isAssignableFrom(ActionEvent.class)) { return actionEvent; } if (javax.swing.Action.class.isAssignableFrom(pType) && pType.isInstance(this)) { return this; } if (InjectionStack.class.equals(pType)) { return context; } if (ApplicationContext.class.isAssignableFrom(pType) && pType.isInstance(app.getContext())) { return app.getContext(); } if (Application.class.isAssignableFrom(pType) && pType.isInstance(app)) { return app; } // Cannot happened, the method parameters have been checked throw new IllegalArgumentException("Unexpected Action method parameter:" + pType.getName()); } private <T, V> Task.InputBlocker createInputBlocker(Task<T, V> task, ActionEvent event) { Object target = event.getSource(); if (block == Task.BlockingScope.ACTION) { target = this; } return new DefaultInputBlocker(task, block, target, app); } /** * This method implements this <tt>Action's</tt> behavior. * <p> * If there's a proxy Action then call its actionPerformed method. Otherwise, call the * @Action method with parameter values provided by {@code getActionArgument()}. If * anything goes wrong call {@code actionFailed()}. * * @param actionEvent @{inheritDoc} * @see #setProxy * @see #getActionArgument * @see Task */ public void actionPerformed(ActionEvent actionEvent) { if (actionBean == null) { return; } Object taskObject = null; /* Create the arguments array for actionMethod by * calling getActionArgument() for each parameter. */ try { Class<?>[] pTypes = actionMethod.getParameterTypes(); Object[] arguments = new Object[pTypes.length]; for (int i = 0; i < pTypes.length; i++) { arguments[i] = getActionArgument(pTypes[i], actionEvent); } /* * Call target.actionMethod(arguments). If the return value * is a Task, then execute it. */ taskObject = actionMethod.invoke(actionBean, arguments); if (taskObject instanceof Task) { Object source = actionEvent.getSource(); Locale taskLocale = locale; if (source instanceof Component) { taskLocale = ((Component) source).getLocale(); } Task<?, ?> task = (Task<?, ?>) taskObject; if (task.getInputBlocker() == null) { task.setInputBlocker(createInputBlocker(task, actionEvent)); } task.setLocale(taskLocale); // Structural injection app.getContext().getResourceManager().inject(task, taskLocale); // Contextual injection from action context.inject(TASK_PROP, task); app.getContext().getTaskService().execute(task); } } catch (Exception e) { actionFailed(actionEvent, e); } } @SuppressWarnings("unchecked") <T> T getProperty(String method, String name) { if (method != null) { try { return (T) getInvoker().getProperty(actionBean, method); } catch (Exception e) { throw newInvokeException(method, e); // error ? } } else { return (T) super.getValue(name); } } <T> void setProperty(String method, boolean proxy, String name, Object value) { if (proxy && (method != null)) { try { getInvoker().setProperty(actionBean, method, value); } catch (Exception e) { throw newInvokeException(method, e); } // If actionBean fire the change and this action is listening : // change will be not be triggered // Otherwise we need to propagate the change firePropertyChange(name, getValue(name), value); } else { super.putValue(name, value); } } private ResourceManager getInvoker() { return app.getContext().getResourceManager(); } /** * If the proxy action is null and {@code enabledProperty} was * specified, then return the value of the enabled property's * is/get method applied to our ApplicationActionMap's * {@code actionsObject}. * Otherwise return the value of this Action's enabled property. * * @return {@inheritDoc} * @see #setProxy * @see #setEnabled * @see ApplicationActionMap#getActionsObject */ @Override public boolean isEnabled() { return getProperty(enabledProperty, ENABLED_KEY); } /** * If the proxy action is null and {@code enabledProperty} was * specified, then set the value of the enabled property by * invoking the corresponding {@code set} method on our * ApplicationActionMap's {@code actionsObject}. * Otherwise set the value of this Action's enabled property. * * @param enabled {@inheritDoc} * @see #setProxy * @see #isEnabled * @see ApplicationActionMap#getActionsObject */ @Override public void setEnabled(boolean enabled) { setProperty(enabledProperty, enabledWrittable, ENABLED_KEY, enabled); } /** * If the proxy action is null and {@code selectedProperty} was * specified, then return the value of the selected property's * is/get method applied to our ApplicationActionMap's {@code actionsObject}. * Otherwise return the value of this Action's enabled property. * * @return true if this Action's JToggleButton is selected * @see #setProxy * @see #setSelected * @see ApplicationActionMap#getActionsObject */ public Boolean isSelected() { return getProperty(selectedProperty, SELECTED_KEY); } /** * If the proxy action is null and {@code selectedProperty} was * specified, then set the value of the selected property by * invoking the corresponding {@code set} method on our * ApplicationActionMap's {@code actionsObject}. * Otherwise set the value of this Action's selected property. * * @param selected this Action's JToggleButton's value * @see #setProxy * @see #isSelected * @see ApplicationActionMap#getActionsObject */ @Override public void setSelected(Boolean selected) { setProperty(selectedProperty, selectedWrittable, SELECTED_KEY, selected); } /** * Keeps the {@code @Action selectedProperty} in sync when * the value of {@code key} is {@code Action.SELECTED_KEY}. * * @param key {@inheritDoc} * @param value {@inheritDoc} */ @Override public void putValue(String key, Object value) { if (SELECTED_KEY.equals(key) && (value instanceof Boolean) && selectedWrittable) { setSelected((Boolean) value); } else if (ENABLED_KEY.equals(key) && (value instanceof Boolean) && enabledWrittable) { setEnabled((Boolean) value); } else { super.putValue(key, value); } } /* Throw an Error because invoking Method m on the actionsObject, * with the specified arguments, failed. */ private RuntimeException newInvokeException(String m, Exception e, Object... args) { String argsString = (args.length == 0) ? "" : args[0].toString(); for (int i = 1; i < args.length; i++) { argsString += ", " + args[i]; } String actionsClassName = actionBean.getClass().getName(); String msg = actionsClassName + "." + m + "(" + argsString + ") failed"; return new UnsupportedOperationException(msg, e); } /* Log enough output for a developer to figure out * what went wrong. */ private void actionFailed(ActionEvent actionEvent, Throwable cause) { app.getContext().getTaskService().failed(actionEvent, cause); } @Override public String toString() { if (!context.isEmpty()) { return context.toString(); } StringBuilder sb = new StringBuilder(getClass().getName()); sb.append("@"); Object nameValue = getValue(javax.swing.Action.NAME); // [getName()].Action.text if (nameValue instanceof String) { sb.append(" \""); sb.append((String)nameValue); sb.append("\""); } return sb.toString(); } }