From commits-return-7865-archive-asf-public=cust-asf.ponee.io@netbeans.apache.org Thu Aug 9 13:09:45 2018 Return-Path: X-Original-To: archive-asf-public@cust-asf.ponee.io Delivered-To: archive-asf-public@cust-asf.ponee.io Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by mx-eu-01.ponee.io (Postfix) with SMTP id 070C8180676 for ; Thu, 9 Aug 2018 13:09:41 +0200 (CEST) Received: (qmail 68598 invoked by uid 500); 9 Aug 2018 11:09:41 -0000 Mailing-List: contact commits-help@netbeans.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Delivered-To: mailing list commits@netbeans.apache.org Received: (qmail 68589 invoked by uid 99); 9 Aug 2018 11:09:41 -0000 Received: from ec2-52-202-80-70.compute-1.amazonaws.com (HELO gitbox.apache.org) (52.202.80.70) by apache.org (qpsmtpd/0.29) with ESMTP; Thu, 09 Aug 2018 11:09:41 +0000 Received: by gitbox.apache.org (ASF Mail Server at gitbox.apache.org, from userid 33) id 4F04A822B2; Thu, 9 Aug 2018 11:09:40 +0000 (UTC) Date: Thu, 09 Aug 2018 11:09:40 +0000 To: "commits@netbeans.apache.org" Subject: [incubator-netbeans] branch master updated: Initial implementation of @ActionState support (#652) MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Message-ID: <153381298019.4964.12394936172445621601@gitbox.apache.org> From: sdedic@apache.org X-Git-Host: gitbox.apache.org X-Git-Repo: incubator-netbeans X-Git-Refname: refs/heads/master X-Git-Reftype: branch X-Git-Oldrev: 97475ecf5ada8a79b48b8c68b54b3901975d2559 X-Git-Newrev: af373e7702d1f9a09a09c33b2ea1f15ae4bf6310 X-Git-Rev: af373e7702d1f9a09a09c33b2ea1f15ae4bf6310 X-Git-NotificationType: ref_changed_plus_diff X-Git-Multimail-Version: 1.5.dev Auto-Submitted: auto-generated This is an automated email from the ASF dual-hosted git repository. sdedic pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-netbeans.git The following commit(s) were added to refs/heads/master by this push: new af373e7 Initial implementation of @ActionState support (#652) af373e7 is described below commit af373e7702d1f9a09a09c33b2ea1f15ae4bf6310 Author: Svatopluk Dedic AuthorDate: Thu Aug 9 13:09:37 2018 +0200 Initial implementation of @ActionState support (#652) Initial implementation of @ActionState support --- openide.awt/apichanges.xml | 22 + openide.awt/manifest.mf | 2 +- openide.awt/nbproject/project.xml | 2 +- .../modules/openide/awt/ActionProcessor.java | 233 +++- .../modules/openide/awt/DefaultAWTBridge.java | 9 +- .../src/org/openide/awt/ActionDefaultPerfomer.java | 17 + .../src/org/openide/awt/ActionRegistration.java | 24 + openide.awt/src/org/openide/awt/ActionState.java | 273 ++++ openide.awt/src/org/openide/awt/Actions.java | 135 +- .../src/org/openide/awt/AlwaysEnabledAction.java | 2 +- openide.awt/src/org/openide/awt/ContextAction.java | 346 ++++- .../src/org/openide/awt/ContextManager.java | 60 +- openide.awt/src/org/openide/awt/GeneralAction.java | 193 ++- openide.awt/src/org/openide/awt/InjectorAny.java | 30 +- .../src/org/openide/awt/InjectorExactlyOne.java | 28 +- .../src/org/openide/awt/PropertyMonitor.java | 651 +++++++++ .../src/org/openide/awt/StatefulAction.java | 140 ++ .../modules/openide/awt/ActionProcessorTest.java | 3 +- .../openide/awt/StatefulActionProcessorTest.java | 1380 ++++++++++++++++++++ openide.util.ui/apichanges.xml | 16 + openide.util.ui/manifest.mf | 2 +- .../openide/util/actions/BooleanStateAction.java | 5 +- 22 files changed, 3470 insertions(+), 103 deletions(-) diff --git a/openide.awt/apichanges.xml b/openide.awt/apichanges.xml index 3c615af..889caf4 100644 --- a/openide.awt/apichanges.xml +++ b/openide.awt/apichanges.xml @@ -26,6 +26,28 @@ AWT API + + + Support model-based enabled and check state of actions + + + + + +

+ Context Actions which provide Action.SELECTED_KEY value will be presented as checkbox (menu) or toggle button (toolbar) and + will reflect the state of an underlying model bean property, tracking models in Lookup and updating to model changes. Similar support + was added for enabled Action property. +

+

+ All that was required to be implemented by individual Actions in their ContextAwareAction + implementations, this allows to use just annotation for the same behaviour in most cases. +

+
+ + + +
Add notification category to the NotificationDisplayer API diff --git a/openide.awt/manifest.mf b/openide.awt/manifest.mf index eb6bb71..c1027ad 100644 --- a/openide.awt/manifest.mf +++ b/openide.awt/manifest.mf @@ -2,5 +2,5 @@ Manifest-Version: 1.0 OpenIDE-Module: org.openide.awt OpenIDE-Module-Localizing-Bundle: org/openide/awt/Bundle.properties AutoUpdate-Essential-Module: true -OpenIDE-Module-Specification-Version: 7.70 +OpenIDE-Module-Specification-Version: 7.71 diff --git a/openide.awt/nbproject/project.xml b/openide.awt/nbproject/project.xml index 4e01faf..7d008a9 100644 --- a/openide.awt/nbproject/project.xml +++ b/openide.awt/nbproject/project.xml @@ -47,7 +47,7 @@ - 9.3 + 9.11 diff --git a/openide.awt/src/org/netbeans/modules/openide/awt/ActionProcessor.java b/openide.awt/src/org/netbeans/modules/openide/awt/ActionProcessor.java index 3ee6e66..483e3c4 100644 --- a/openide.awt/src/org/netbeans/modules/openide/awt/ActionProcessor.java +++ b/openide.awt/src/org/netbeans/modules/openide/awt/ActionProcessor.java @@ -21,6 +21,7 @@ package org.netbeans.modules.openide.awt; import java.awt.event.ActionListener; import java.util.Collections; +import java.util.EventListener; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -41,6 +42,8 @@ import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import javax.swing.Action; @@ -51,6 +54,8 @@ import org.openide.awt.ActionID; import org.openide.awt.ActionReference; import org.openide.awt.ActionReferences; import org.openide.awt.ActionRegistration; +import org.openide.awt.ActionState; +import org.openide.awt.Actions; import org.openide.awt.DynamicMenuContent; import org.openide.filesystems.annotations.LayerBuilder; import org.openide.filesystems.annotations.LayerBuilder.File; @@ -83,6 +88,7 @@ public final class ActionProcessor extends LayerGeneratingProcessor { hash.add(ActionID.class.getCanonicalName()); hash.add(ActionReference.class.getCanonicalName()); hash.add(ActionReferences.class.getCanonicalName()); +// hash.add(ActionState.class.getCanonicalName()); return hash; } @@ -156,6 +162,11 @@ public final class ActionProcessor extends LayerGeneratingProcessor { TypeMirror p3 = type(Presenter.Popup.class); TypeMirror caa = type(ContextAwareAction.class); TypeMirror dmc = type(DynamicMenuContent.class); + TypeMirror at = type(Action.class); + TypeMirror ot = type(Object.class); + TypeMirror lt = type(EventListener.class); + TypeMirror vt = type(Void.class); + for (Element e : roundEnv.getElementsAnnotatedWith(ActionRegistration.class)) { ActionRegistration ar = e.getAnnotation(ActionRegistration.class); if (ar == null) { @@ -260,6 +271,7 @@ public final class ActionProcessor extends LayerGeneratingProcessor { } f.instanceAttribute("instanceCreate", Action.class); } else { + TypeMirror selectType = null; if (key.length() == 0) { f.methodvalue("instanceCreate", "org.openide.awt.Actions", "alwaysEnabled"); } else { @@ -273,7 +285,7 @@ public final class ActionProcessor extends LayerGeneratingProcessor { try { f.instanceAttribute("delegate", ActionListener.class, ar, null); } catch (LayerGenerationException ex) { - generateContext(e, f, ar); + selectType = generateContext(e, f, ar); } } if (ar.iconBase().length() > 0) { @@ -285,8 +297,10 @@ public final class ActionProcessor extends LayerGeneratingProcessor { f.boolvalue("asynchronous", true); } if (ar.surviveFocusChange()) { - f.boolvalue("surviveFocusChange", true); + f.boolvalue("surviveFocusChange", true); } + processActionState(e, ar.enabledOn(), f, selectType, true, at, ot, lt, vt); + processActionState(e, ar.checkedOn(), f, selectType, false, at, ot, lt, vt); } f.write(); @@ -344,13 +358,222 @@ public final class ActionProcessor extends LayerGeneratingProcessor { } return true; } + + private void processActionState(Element e, ActionState as, File f, TypeMirror selectType, boolean enable, + TypeMirror actionType, TypeMirror objectType, TypeMirror eventListenerType, TypeMirror voidType) + throws LayerGenerationException { + String property = as.property(); + TypeMirror enabledType = null; + try { + as.type(); + } catch (MirroredTypeException mte) { + enabledType = mte.getTypeMirror(); + } + if (enabledType == null || enabledType.getKind() != TypeKind.DECLARED) { + throw new LayerGenerationException("Invalid enabled-on type in @ActionState", e, processingEnv, as, "type"); + } + if (processingEnv.getTypeUtils().isSameType(enabledType, voidType)) { + return; + } + if (!as.useActionInstance()) { + if (processingEnv.getTypeUtils().isSameType(enabledType, objectType) && "".equals(as.property())) { + if (!enable) { + throw new LayerGenerationException("Property must be specified", e, processingEnv, as); + } + } + } + DeclaredType dt = (DeclaredType) enabledType; + if (processingEnv.getTypeUtils().isSameType(dt, objectType)) { + if (selectType == null) { + throw new LayerGenerationException("Property owner type must be specified", e, processingEnv, as); + } + dt = (DeclaredType)selectType; + } + String dtName = processingEnv.getElementUtils().getBinaryName((TypeElement)dt.asElement()).toString(); + + f.stringvalue(enable ? "enableOnType" : "checkedOnType", dtName); + + if (!enable) { + f.boolvalue(Actions.ACTION_VALUE_TOGGLE, true); + } + + boolean isAction = processingEnv.getTypeUtils().isSameType(dt, actionType); + switch (property) { + case "": + if (as.useActionInstance()) { + property = null; + break; + } + property = enable ? "enabled" : Action.SELECTED_KEY; break; + case ActionState.NULL_VALUE: property = null; + } + + TypeElement tel = (TypeElement)dt.asElement(); + if (property != null && !isAction) { + ExecutableElement getter = null; + ExecutableElement invalidGetter = null; + + String capitalizedName = Character.toUpperCase(property.charAt(0)) + property.substring(1); + String isGetter = "is" + capitalizedName; + String getGetter = "get" + capitalizedName; + + for (ExecutableElement el : ElementFilter.methodsIn(processingEnv.getElementUtils().getAllMembers(tel))) { + if (el.getSimpleName().contentEquals(isGetter)) { + if (!el.getParameters().isEmpty()) { + invalidGetter = el; + } else { + getter = el; + break; + } + } + if (el.getSimpleName().contentEquals(getGetter)) { + if (!el.getParameters().isEmpty()) { + if (invalidGetter == null) { + invalidGetter = el; + } + } else { + getter = el; + } + } + } + + if (getter == null) { + if (invalidGetter != null) { + throw new LayerGenerationException("Getter " + dtName + "." + invalidGetter.toString() + " must take no parameters", + e, processingEnv, as, "property"); + } else { + throw new LayerGenerationException("Property " + property + " not found in " + dtName + ".", + e, processingEnv, as, "property"); + } + } + + Set mods = getter.getModifiers(); + if (!mods.contains(Modifier.PUBLIC)) { + throw new LayerGenerationException("Getter " + dtName + "." + getter.toString() + " must be public", + e, processingEnv, as, "property"); + } + } + if (property != null) { + f.stringvalue(enable ? "enableOnProperty" : "checkedOnProperty", property); // NOI18N + } + + TypeMirror listenType = null; + try { + as.listenOn(); + return; + } catch (MirroredTypeException ex) { + listenType = ex.getTypeMirror(); + } + boolean explicitListenerType = !processingEnv.getTypeUtils().isSameType(listenType, eventListenerType); + + TypeElement lfaceElement = (TypeElement)((DeclaredType)listenType).asElement(); + String lfaceName = lfaceElement.getSimpleName().toString(); + String lfaceFQN = processingEnv.getElementUtils().getBinaryName(lfaceElement).toString(); + String addName = "add" + lfaceName; + String removeName = "remove" + lfaceName; + + if (explicitListenerType) { + if (lfaceElement.getKind() != ElementKind.INTERFACE) { + throw new LayerGenerationException(lfaceFQN + " is not an interface", e, processingEnv, as, "listenOn"); + } + if (!lfaceElement.getModifiers().contains(Modifier.PUBLIC)) { + throw new LayerGenerationException(lfaceFQN + " is not public", e, processingEnv, as, "listenOn"); + } + } + + ExecutableElement addMethod = null; + ExecutableElement addCandidate = null; + ExecutableElement removeMethod = null; + ExecutableElement removeCandidate = null; + for (ExecutableElement el : ElementFilter.methodsIn(processingEnv.getElementUtils().getAllMembers(tel))) { + if (el.getSimpleName().contentEquals(addName)) { + addCandidate = el; + if (!el.getModifiers().contains(Modifier.PUBLIC) || el.getModifiers().contains(Modifier.STATIC)) { + continue; + } + if (el.getParameters().size() == 1 && + processingEnv.getTypeUtils().isSameType(listenType, el.getParameters().get(0).asType())) { + addMethod = el; + } + } else if (el.getSimpleName().contentEquals(removeName)) { + removeCandidate = el; + if (!el.getModifiers().contains(Modifier.PUBLIC) || el.getModifiers().contains(Modifier.STATIC)) { + continue; + } + if (el.getParameters().size() == 1 && + processingEnv.getTypeUtils().isSameType(listenType, el.getParameters().get(0).asType())) { + removeMethod = el; + } + } + } + if (addMethod == null) { + if (addCandidate != null) { + throw new LayerGenerationException("Method add" + + addCandidate.getSimpleName() + " must be public and take exactly one parameter of type " + + lfaceName + ".", e, processingEnv, as, "listenOn"); + } else if (explicitListenerType) { + throw new LayerGenerationException("Method add" + + lfaceName + " not found on " + dtName, e, processingEnv, as, "listenOn"); + } + } + if (removeMethod == null) { + if (removeCandidate != null) { + throw new LayerGenerationException("Method remove" + + removeCandidate.getSimpleName() + " must be public and take exactly one parameter of type " + + lfaceName + ".", e,processingEnv, as, "listenOn"); + } else if (explicitListenerType) { + throw new LayerGenerationException("Method remove" + + lfaceName + " not found on " + dtName, e, processingEnv, as, "listenOn"); + } + } + boolean wantsListen = explicitListenerType || (addMethod != null && removeMethod != null); + if (wantsListen) { + f.stringvalue(enable ? "enableOnChangeListener" : "checkedOnChangeListener", lfaceFQN); + } + if (!"".equals(as.listenOnMethod())) { + if (!explicitListenerType) { + throw new LayerGenerationException("Cannot specify listenOnMethod() without listenOn().", e,processingEnv, as, "listenOnMethod"); + } + String m = as.listenOnMethod(); + boolean found = false; + for (ExecutableElement el : ElementFilter.methodsIn(processingEnv.getElementUtils().getAllMembers(lfaceElement))) { + if (el.getSimpleName().contentEquals(m)) { + found = true; + break; + } + } + if (!found) { + throw new LayerGenerationException("Interface " + lfaceFQN + " does not contain method " + m, + e, processingEnv, as, "listenOnMethod"); + } + f.stringvalue(enable ? "enableOnMethod" : "checkedOnMethod", m); + } + + if (!"".equals(as.checkedValue())) { + switch (as.checkedValue()) { + case ActionState.NULL_VALUE: + f.boolvalue(enable ? "enableOnNull" : "checkedOnNull", true); + break; + case ActionState.NON_NULL_VALUE: + f.boolvalue(enable ? "enableOnNull" : "checkedOnNull", false); + break; + default: + f.stringvalue(enable ? "enableOnValue" : "checkedOnValue", as.checkedValue()); + break; + } + } + if (as.useActionInstance()) { + f.stringvalue(enable ? "enableOnActionProperty" : "checkedOnActionProperty", + enable ? "enabled" : Action.SELECTED_KEY); + } + } private TypeMirror type(Class type) { final TypeElement e = processingEnv.getElementUtils().getTypeElement(type.getCanonicalName()); return e == null ? null : e.asType(); } - private void generateContext(Element e, File f, ActionRegistration ar) throws LayerGenerationException { + private TypeMirror generateContext(Element e, File f, ActionRegistration ar) throws LayerGenerationException { ExecutableElement ee = null; ExecutableElement candidate = null; for (ExecutableElement element : ElementFilter.constructorsIn(e.getEnclosedElements())) { @@ -396,7 +619,7 @@ public final class ActionProcessor extends LayerGeneratingProcessor { f.stringvalue("injectable", processingEnv.getElementUtils().getBinaryName((TypeElement) e).toString()); f.stringvalue("selectionType", "ANY"); f.methodvalue("instanceCreate", "org.openide.awt.Actions", "context"); - return; + return dt.getTypeArguments().get(0); } if (!dt.getTypeArguments().isEmpty()) { throw new LayerGenerationException("No type parameters allowed in ", ee); @@ -407,7 +630,9 @@ public final class ActionProcessor extends LayerGeneratingProcessor { f.stringvalue("injectable", processingEnv.getElementUtils().getBinaryName((TypeElement)e).toString()); f.stringvalue("selectionType", "EXACTLY_ONE"); f.methodvalue("instanceCreate", "org.openide.awt.Actions", "context"); + return ctorType; } + private String binaryName(TypeMirror t) { Element e = processingEnv.getTypeUtils().asElement(t); if (e != null && (e.getKind().isClass() || e.getKind().isInterface())) { diff --git a/openide.awt/src/org/netbeans/modules/openide/awt/DefaultAWTBridge.java b/openide.awt/src/org/netbeans/modules/openide/awt/DefaultAWTBridge.java index 1a585c2..fa21822 100644 --- a/openide.awt/src/org/netbeans/modules/openide/awt/DefaultAWTBridge.java +++ b/openide.awt/src/org/netbeans/modules/openide/awt/DefaultAWTBridge.java @@ -36,6 +36,7 @@ import org.openide.util.actions.BooleanStateAction; import org.openide.util.actions.SystemAction; import org.openide.util.lookup.ServiceProvider; import org.openide.util.actions.ActionPresenterProvider; +import org.openide.util.actions.Presenter; /** Default implementation of presenters for various action types. */ @@ -46,6 +47,9 @@ public final class DefaultAWTBridge extends ActionPresenterProvider { BooleanStateAction b = (BooleanStateAction)action; return new Actions.CheckboxMenuItem (b, true); } + if (action.getValue(Actions.ACTION_VALUE_TOGGLE) != null) { + return new Actions.CheckboxMenuItem(action, true); + } if (action instanceof SystemAction) { SystemAction s = (SystemAction)action; return new Actions.MenuItem (s, true); @@ -68,11 +72,12 @@ public final class DefaultAWTBridge extends ActionPresenterProvider { return item; } + @Override public Component createToolbarPresenter(Action action) { AbstractButton btn; - if (action instanceof BooleanStateAction) { + if ((action instanceof BooleanStateAction) || (action.getValue(Actions.ACTION_VALUE_TOGGLE) != null)) { btn = new JToggleButton(); - Actions.connect(btn, (BooleanStateAction) action); + Actions.connect(btn, action); } else { btn = new JButton(); Actions.connect(btn, action); diff --git a/openide.awt/src/org/openide/awt/ActionDefaultPerfomer.java b/openide.awt/src/org/openide/awt/ActionDefaultPerfomer.java index c2d1dbf..b4448d0 100644 --- a/openide.awt/src/org/openide/awt/ActionDefaultPerfomer.java +++ b/openide.awt/src/org/openide/awt/ActionDefaultPerfomer.java @@ -21,6 +21,8 @@ package org.openide.awt; import java.awt.event.ActionEvent; import java.util.Collections; import java.util.List; +import java.util.Objects; +import static javax.swing.Action.ACTION_COMMAND_KEY; import org.netbeans.api.actions.Closable; import org.netbeans.api.actions.Editable; import org.netbeans.api.actions.Openable; @@ -61,4 +63,19 @@ final class ActionDefaultPerfomer extends ContextAction.Performer { } } } + + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + Object o = delegate.get("key"); // NOI18N + if (o == null) { + o = delegate.get(ACTION_COMMAND_KEY); + } + Object d= instDelegate == null ? null : instDelegate.get(); + sb.append("PerformerDefault{id = ").append(Objects.toString(o)) + .append(", type = ").append(type) + .append("}"); + return sb.toString(); + } } diff --git a/openide.awt/src/org/openide/awt/ActionRegistration.java b/openide.awt/src/org/openide/awt/ActionRegistration.java index 372dd0d..d897d27 100644 --- a/openide.awt/src/org/openide/awt/ActionRegistration.java +++ b/openide.awt/src/org/openide/awt/ActionRegistration.java @@ -127,4 +127,28 @@ public @interface ActionRegistration { */ boolean lazy() default true; + /** + * Specifies a property that enables the action for context-sensitive actions. + * The property can be on the context object (implies single selection mode), or on another object + * type in the context Lookup. The default enables the action if the context + * object is present (with no additional constraints). + *

+ * Specify the value if the action should be enabled based on certain property and + * its value. See {@link ActionState} for detailed explanation of the + * state evaluation and tracking. + * + * @return the specification of enabled state + * @since 7.71 + */ + ActionState enabledOn() default @ActionState(type=Void.class); + + /** + * Controls action's enable state. If unspecified, the action will not represent the state value, + * and will be presented as normal item or button. If specified, the action will be presented as + * checkbox or toggle button. * Similar to {@link #enableOn}, type and its property can be used to determine whether the + * action is checked or unchecked. See {@link ActionState} for more details. + * @return specification of the checked state. + * @since 7.71 + */ + ActionState checkedOn() default @ActionState(type=Void.class); } diff --git a/openide.awt/src/org/openide/awt/ActionState.java b/openide.awt/src/org/openide/awt/ActionState.java new file mode 100644 index 0000000..fda05ba --- /dev/null +++ b/openide.awt/src/org/openide/awt/ActionState.java @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.openide.awt; + +import java.beans.PropertyChangeListener; +import javax.swing.event.ChangeListener; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.EventListener; +import javax.swing.Action; +import org.openide.util.actions.Presenter; + +/** + * Specifies that the action behaviour is conditional and how the action should obtain the + * state for its presentation. The annotation is used as value for {@link ActionRegistration#enabledOn} + * and {@link ActionRegistration#checkedOn} to control action's enabled or checked state. The annotation + * can be only applied on context actions, which have a single parameter constructor, + * which accept the model object - see {@link Actions#context(java.lang.Class, boolean, boolean, org.openide.util.ContextAwareAction, java.lang.String, java.lang.String, java.lang.String, boolean)}. + *

+ * When used as {@link ActionRegistration#checkedOn} value, the annotated action will change + * to toggle on/off action, represented by a checkbox (menu) or a toggle button (toolbar). + * The action state will track the model property specified by this + * annotation. Toggle actions become enabled when the model object is + * found in the Lookup, and checked (or toggled on) when the model property + * is set to a defined value (usually {@code true}) + *

+ * The {@link #type} specifies type which is searched for in the {@link Lookup} and + * if an instance is found, it is used as the model object. If the {@link #type} is not set, + * the the type inferred from Action's + * constructor (see {@link ActionRegistration}) will be used to find the model. + *

+ * The {@link #property} specifies bean property whose value should be used to + * determine checked state. The obtained value is compared using {@link #checkedValue} + * as follows: + *

    + *
  • a boolean or Boolean value is compared to {@link Boolean#TRUE} or the {@link #checkedValue}, + * if present. + *
  • if {@link #checkValue} is {@link #NULL_VALUE}, the action is checked if and only if + * the value is {@code null}. + *
  • if {@link #checkValue} is {@link #NON_NULL_VALUE}, the action is checked if and only if + * the value is not {@code null}. + *
  • if the value type is an enum, its {@link Enum#name} is compared to {@link #checkValue} + *
  • the state will be {@code false} (unchecked) otherwise. + *

    + * If {@link #type} is set to {@link Action}.class, the annotated element must + * be an {@link Action} subclass. {@link Action#getValue} will be used to determine + * the state. The Action delegate will not be instantiated eagerly, but only + * after the necessary context type becomes available in Lookup. + * This support minimizes from premature code loading for custom action implementations. + * Important note: if your Action implements {@link ContextAwareAction}, + * or one of the {@link Presenter} interfaces, it is eager and will be loaded immediately ! + *

    + * Changes to the model object will be tracked using event listener pattern. The annotation-supplied + * delegate attempts to {@link PropertyChangeListener} and {@link ChangeListener} automatically; \ + * other listener interfaces must be specified using {@link #listenOn} + * value. Finally, {@link #listenOnMethod} specifies which listener method will trigger + * state update; by default, all listener method calls will update the action state. + *

    + * The {@link ActionState} annotation may be also used as a value of {@link ActionRegistration#enabledOn()} + * and causes the annotated Action to be enabled not only on presence of object of the context type, + * but also based on the model property. The property, enable value and listener is specified the + * same way as for "checked" state. See the above text. + *

    + * If a completely custom behaviour is desired, the system can finally delegate {@link Action#isEnabled} and + * {@link Action#getValue getValue}({@link Action#SELECTED_KEY}) to the action implementation itself: use {@link #useActionInstance()} + * value. + *

    + * Here are several examples of {@code @ActionState} usage: + *

    + * To define action, which enables on modified DataObjects do the following + * registration: + *

    + * @ActionID(category = "Example", id = "example.SaveAction")
    + * @ActionRegistration(displayName = "Save modified",
    + *     enabledOn = @ActionState(property = "modified")
    + * )
    + * public class ExampleAction implements ActionListener {
    + *     public ExampleAction(DataObject d) {
    + *         // ...
    + *     }
    + *     
    + *     public void actionPerformed(ActionEvent e) {
    + *         // ...
    + *     }
    + * }
    + * 
    + * The action will be instantiated and run only after: + *
      + *
    • DataObject becomes available, and + *
    • its {@code modified} property becomes true + *
    + * + * To create "toggle" action in toolbar or a menu, which changes state based on some property, + * you can code: + *
    + * enum SelectionMode {
    + *     Rectangular,
    + *     normal
    + * }
    + * @ActionID(category = "Example", id = "example.RectSelection")
    + * @ActionRegistration(displayName = "Toggle rectangular selection", checkedOn = @ActionState(
    + *     property = "selectionMode", checkedValue = "Rectangular", listenOn = EditorStateListener.class)
    + * )
    + * public class RectangularSelectionAction implements ActionListener {
    + *     public RectangularSelectionAction(EditorInterface editor) {
    + *         // ...
    + *     }
    + *     @Override
    + *     public void actionPerformed(ActionEvent e) {
    + *     }
    + * }
    + * 
    + * The action enables when {@code EditorInterface} appears in the action Lookup. Then, + * its state will be derived from {@code EditorInterface.selectionMode} property. Since + * there's a custom listener interface for this value, it must be specified using {@link #listenOn}. + *

    + * Finally, if the action needs to perform its own special magic to check enable state, we + * hand over final control to the action, but the annotation-introduced wrappers will still + * create action instance for a new model object, attach and detach listeners on it and ensure + * that UI will not be strongly referenced from the model for proper garbage collection: + *

    + * @ActionID(category = "Example", id = "example.SelectPrevious")
    + * @ActionRegistration(displayName = "Selects previous item", checkedOn = @ActionState(
    + *     listenOn = ListSelectionListener.class, useActionInstance = true)
    + * )
    + * public class SelectPreviousAction extends AbstractAction {
    + *     private final ListSelectionModel model;
    + *     
    + *     public SelectPreviousAction(ListSelectionModel model) {
    + *         this.model = model;
    + *     }
    + *     @Override
    + *     public boolean isEnabled() {
    + *         return model.getAnchorSelectionIndex() > 0;
    + *     }
    + *     @Override
    + *     public void actionPerformed(ActionEvent e) {
    + *     }
    + * }
    + * 
    + * The system will do the necessary bookkeeping, but the action decides using its + * {@link Action#isEnabled} implementation. + * + * @author sdedic + * @since 7.71 + */ +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.FIELD}) +public @interface ActionState { + + /** + * The type which the action will look for in its context. The action + * becomes checked (enabled) if and only if there's at least one instance of such type + * present in the context. There are some special values that modify behaviour: + *
      + *
    • Object.class (the default) the context object will be used and the property will be read + * from the context object. Only applicable if the action accepts single object. + *
    • Action.class: the {@link #property} action value will be used, + * as obtained by {@link Action#getValue}. + *
    + * If {@code @ActionState} is used in {@link ActionRegistration#enabledOn()}, the + * {@code type} can be left unspecified, defaulting to the context type for the action. + * + * @return type to work with. + */ + public Class type() default Object.class; + + /** + * Property name whose value represents the state. The property must be a + * property on the {@link #type()} class; read-only properties are + * supported. If the target class supports attaching + * {@link PropertyChangeListener} or {@link ChangeListener}, the action will + * attach a listener ({@link PropertyChangeListener} takes precedence) and will fire + * appropriate state events when the property property changes. + *

    + * In the case that checked state is delegated to {@link Action}, the property + * default is different depending on the context the annotation is used: + *

      + *
    • if used to specify enable state ({@link ActionRegistration#enabledOn()}, the property defaults to "enabled" + *
    • if used as checked state ({@code @ActionState} directly annotates to element}, the property defaults to {@link Action#SELECTED_KEY}. + *
    • if the model is {@link Action}, {@link Action#getValue} is also used + * to obtain the value. + *
    + * Note that although this value gives more flexibility than {@link #useActionInstance()} for Actions, + * in the case where {@link #type}.{@link #property} is used to specify necessary guard condition, + * {@link #useActionInstance()} is necessary to perform custom check. + * @return property name. + */ + public String property() default ""; // NOI18N + + /** + * The value which makes the action checked. Can be one of: + *
      + *
    • "true", "false" to represent boolean or Boolean values + *
    • String representation of an enum value, as obtained by {@link Enum#name()} + *
    • {@link #NULL_VALUE} to indicate null value + *
    • {@link #NON_NULL_VALUE} to indicate any non-null value + *
    • String representation of the value object, as obtained by {@link Object#toString} + *
    • + *
    + * @return value which indicates "set" state + */ + public String checkedValue() default ""; // NOI18N + + /** + * Custom listener interface to monitor for changes. If undefined, then + * either {@link PropertyChangeListener} or {@link ChangeListener} will be + * auto-detected from {@link #type} class. + *

    + * All listener methods will cause the system to re-evaluate enable and on/off + * (if applicable) state for the action, unless {@link #listenOnMethod} is + * also used. + * + * @return custom listener interface. + */ + public Class listenOn() default EventListener.class; + + /** + * Allows to pick one listener method, which will trigger action state update. + * The update will re-check both enable and on/off state (if applicable). + * The action will however fire change events only if the state actually changes + * from the previous one. + *

    + * The default (empty) value means that all listener methods will cause + * state update. The value can be only specified together with {@link #listenOn} value. + * + * @return listener method. + */ + public String listenOnMethod() default ""; // NOI18N + + /** + * If true, the target system will delegate to the action instance itself. + * The action instance will not be created until the context object (or {@link #type()} + * becomes available and the guard {@link #property()} has the {@link #checkedValue() appropriate value}. + *

    + * After that, the system will delegate to {@link Action#isEnabled()} for enablement, or + * to {@link Action#getValue getValue}({@link Action#SELECTED_KEY}) for on/off state of the action. + *

    + * The annotated element must implement {@link Action} interface in order to use + * this value. + * @return whether the action instance itself should be ultimately for enable/check status + */ + public boolean useActionInstance() default false; + + /** + * An explicit {@code null} value for {@link #checkedValue}, represents {@code null} + */ + public static final String NULL_VALUE = "#null"; + + /** + * An explicit {@code null} value for {@link #checkedValue}, represents {@code non-null} + */ + public static final String NON_NULL_VALUE = "#non-null"; +} diff --git a/openide.awt/src/org/openide/awt/Actions.java b/openide.awt/src/org/openide/awt/Actions.java index 8675c3a..afacb480 100644 --- a/openide.awt/src/org/openide/awt/Actions.java +++ b/openide.awt/src/org/openide/awt/Actions.java @@ -65,6 +65,7 @@ import org.openide.util.LookupListener; import org.openide.util.Utilities; import org.openide.util.WeakListeners; import org.openide.util.actions.BooleanStateAction; +import org.openide.util.actions.Presenter; import org.openide.util.actions.SystemAction; @@ -81,6 +82,15 @@ public class Actions { public static final String ACTION_VALUE_VISIBLE = "openide.awt.actionVisible"; // NOI18N /** + * Key for {@link Action#getValue} to indicate that the action should be presented + * as toggle, if possible. Presenters may create checkbox item, toggle button etc. + * This is to avoid accessing the {@link Action#SELECTED_KEY} actual value during + * presenter construction, as evaluation may be expensive. + * @since 7.71 + */ + public static final String ACTION_VALUE_TOGGLE = "openide.awt.actionToggle"; // NOI18N + + /** * @deprecated should not be used */ @Deprecated @@ -179,7 +189,12 @@ public class Actions { return; } } - Bridge b = new MenuBridge(item, action, popup); + Bridge b; + if ((item instanceof JCheckBoxMenuItem) && (action.getValue(Actions.ACTION_VALUE_TOGGLE) != null)) { + b = new CheckMenuBridge((JCheckBoxMenuItem)item, action, popup); + } else { + b = new MenuBridge(item, action, popup); + } b.prepare(); if (item instanceof Actions.MenuItem) { @@ -192,12 +207,28 @@ public class Actions { * @param item menu item * @param action action * @param popup create popup or menu item + * @deprecated Please use {@link #connect(javax.swing.JCheckBoxMenuItem, javax.swing.Action, boolean)}. + * Have your action to implement properly {@link Action#getValue} for {@link Action#SELECTED_KEY} */ + @Deprecated public static void connect(JCheckBoxMenuItem item, BooleanStateAction action, boolean popup) { Bridge b = new CheckMenuBridge(item, action, popup); b.prepare(); } + /** Attaches checkbox menu item to boolean state action. The presenter connects to the + * {@link Action#SELECTED_KEY} action value + * + * @param item menu item + * @param action action + * @param popup create popup or menu item + * @since 7.71 + */ + public static void connect(JCheckBoxMenuItem item, Action action, boolean popup) { + Bridge b = new CheckMenuBridge(item, action, popup); + b.prepare(); + } + /** Connects buttons to action. * @param button the button * @param action the action @@ -240,8 +271,17 @@ public class Actions { return; } } - Bridge b = new ButtonBridge(button, action); + Bridge b; + if (action instanceof BooleanStateAction) { + b = new BooleanButtonBridge(button, (BooleanStateAction)action); + } + if (action.getValue(Actions.ACTION_VALUE_TOGGLE) != null) { + b = new BooleanButtonBridge(button, action); + } else { + b = new ButtonBridge(button, action); + } b.prepare(); + button.putClientProperty(DynamicMenuContent.HIDE_WHEN_DISABLED, action.getValue(DynamicMenuContent.HIDE_WHEN_DISABLED)); } /** Connects buttons to action. @@ -634,6 +674,37 @@ public class Actions { * } * } * + *

    + * Further attributes are defined to control action's enabled and checked state. + * Attributes which control enable state are prefixed by "{@code enableOn}". Attributes + * controlling checked state have prefix "{@code checkedOn}": + *

    +     * <file name="action-pkg-ClassName.instance">
    +     *   <!-- Enable on certain type in Lookup -->
    +     *   <attr name="enableOnType" stringvalue="qualified.type.name"/>
    +     * 
    +     *   <!-- Monitor specific property in that type -->
    +     *   <attr name="enableOnProperty" stringvalue="propertyName"/>
    +     * 
    +     *   <!-- The property value, which corresponds to enabled action.
    +     *           Values "#null" and "#non-null" are treated specially.
    +     *   -->
    +     *   <attr name="enableOnValue" stringvalue="propertyName"/>
    +     * 
    +     *   <!-- Name of custom listener interface -->
    +     *   <attr name="enableOnChangeListener" stringvalue="qualifier.listener.interface"/>
    +     * 
    +     *   <!-- Name of listener method that triggers state re-evaluation  -->
    +     *   <attr name="enableOnMethod" stringvalue="methodName"/>
    +     * 
    +     *   <!-- Delegate to the action instance for final decision -->
    +     *   <attr name="enableOnActionProperty" stringvalue="actionPropertyName"/>
    +     * 
    +     *   <!-- ... -->
    +     * 
    +     * </file>
    +     * 
    +     * 
    * * @param type the object to seek for in the active context * @param single shall there be just one or multiple instances of the object @@ -665,7 +736,7 @@ public class Actions { map.put("displayName", displayName); // NOI18N map.put("iconBase", iconBase); // NOI18N map.put("noIconInMenu", noIconInMenu); // NOI18N - return GeneralAction.context(map); + return GeneralAction.context(map, true); } static Action context(Map fo) { Object context = fo.get("context"); @@ -1155,8 +1226,24 @@ public class Actions { /** Bridge for button and boolean action. */ private static class BooleanButtonBridge extends ButtonBridge { - public BooleanButtonBridge(AbstractButton button, BooleanStateAction action) { + private final BooleanStateAction stateAction; + private final PropertyChangeListener bsaL; + + public BooleanButtonBridge(AbstractButton button, BooleanStateAction bsa) { + super(button, bsa); + this.stateAction = bsa; + if (bsa != null && bsa != action) { + bsaL = WeakListeners.propertyChange(this, BooleanStateAction.PROP_BOOLEAN_STATE, bsa); + bsa.addPropertyChangeListener(bsaL); + } else { + bsaL = null; + } + } + + public BooleanButtonBridge(AbstractButton button, Action action) { super(button, action); + this.stateAction = null; + this.bsaL = null; } /** @param changedProperty the name of property that has changed @@ -1166,9 +1253,19 @@ public class Actions { public void updateState(String changedProperty) { super.updateState(changedProperty); - if ((changedProperty == null) || changedProperty.equals(BooleanStateAction.PROP_BOOLEAN_STATE)) { - button.setSelected(((BooleanStateAction) action).getBooleanState()); + if ((changedProperty == null) || + changedProperty.equals(BooleanStateAction.PROP_BOOLEAN_STATE) || + (bsaL == null && changedProperty.equals(Action.SELECTED_KEY))) { + button.setSelected(getBooleanState()); + } + } + + protected boolean getBooleanState() { + if (action instanceof AlwaysEnabledAction.CheckBox) { + return ((AlwaysEnabledAction.CheckBox)action).isPreferencesSelected(); } + return stateAction != null ? stateAction.getBooleanState() : + Boolean.TRUE.equals(action.getValue(Action.SELECTED_KEY)); } } @@ -1329,8 +1426,17 @@ public class Actions { private boolean hasOwnIcon = false; /** Popup menu */ - public CheckMenuBridge(JCheckBoxMenuItem item, BooleanStateAction action, boolean popup) { + public CheckMenuBridge(JCheckBoxMenuItem item, BooleanStateAction bsa, boolean popup) { + super(item, bsa); + init(item, popup); + } + + public CheckMenuBridge(JCheckBoxMenuItem item, Action action, boolean popup) { super(item, action); + init(item, popup); + } + + private void init(JCheckBoxMenuItem item, boolean popup) { this.popup = popup; if (popup) { @@ -1585,10 +1691,25 @@ public class Actions { * and connects it to the given BooleanStateAction. * @param aAction the action to which this menu item should be connected * @param useMnemonic if true, the menu try to find mnemonic in action label + * @deprecated use {@link #CheckboxMenuItem(javax.swing.Action, boolean)}. + * Have your action to implement properly {@link Action#getValue} for {@link Action#SELECTED_KEY} */ public CheckboxMenuItem(BooleanStateAction aAction, boolean useMnemonic) { Actions.connect(this, aAction, !useMnemonic); } + + + /** Constructs a new ActCheckboxMenuItem with the specified label + * and connects it to the given Action and its {@link Action#SELECTED_KEY} + * value. + * + * @param aAction the action to which this menu item should be connected + * @param useMnemonic if true, the menu try to find mnemonic in action label + * @since 7.71 + */ + public CheckboxMenuItem(Action aAction, boolean useMnemonic) { + Actions.connect(this, aAction, !useMnemonic); + } } /** Component shown in toolbar, representing an action. diff --git a/openide.awt/src/org/openide/awt/AlwaysEnabledAction.java b/openide.awt/src/org/openide/awt/AlwaysEnabledAction.java index 6782dc5..d57d65b 100644 --- a/openide.awt/src/org/openide/awt/AlwaysEnabledAction.java +++ b/openide.awt/src/org/openide/awt/AlwaysEnabledAction.java @@ -377,7 +377,7 @@ implements PropertyChangeListener, ContextAwareAction { return new CheckBox(map, this, actionContext, equals); } - private boolean isPreferencesSelected() { + boolean isPreferencesSelected() { String key = (String) getValue(PREFERENCES_KEY); Preferences prefs = prefs(); boolean value; diff --git a/openide.awt/src/org/openide/awt/ContextAction.java b/openide.awt/src/org/openide/awt/ContextAction.java index fdcbeb9..bea899b 100644 --- a/openide.awt/src/org/openide/awt/ContextAction.java +++ b/openide.awt/src/org/openide/awt/ContextAction.java @@ -21,36 +21,53 @@ package org.openide.awt; import java.awt.EventQueue; import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.swing.Action; +import javax.swing.SwingUtilities; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; import org.openide.util.ContextAwareAction; import org.openide.util.Lookup; +import org.openide.util.Mutex; +import org.openide.util.WeakListeners; /** A delegate action that is usually associated with a specific lookup and * listens on certain classes to appear and disappear there. */ -final class ContextAction extends Object -implements Action, ContextAwareAction { +class ContextAction extends Object +implements Action, ContextAwareAction, ChangeListener, Runnable { //, Presenter.Menu, Presenter.Popup, Presenter.Toolbar, PropertyChangeListener { + static final Logger LOG = Logger.getLogger(ContextAction.class.getName()); + /** type to check */ - private final Class type; + final Class type; /** selection mode */ final ContextSelection selectMode; /** performer to call */ - private final ContextAction.Performer performer; + final ContextAction.Performer performer; /** global lookup to work with */ - private final ContextManager global; + final ContextManager global; /** support for listeners */ private PropertyChangeSupport support; /** was this action enabled or not*/ private boolean previousEnabled; + + protected final StatefulMonitor enableMonitor; /** Constructs new action that is bound to given context and * listens for changes of ActionMap in order to delegate @@ -61,7 +78,7 @@ implements Action, ContextAwareAction { ContextSelection selectMode, Lookup actionContext, Class type, - boolean surviveFocusChange + boolean surviveFocusChange, StatefulMonitor enableMonitor ) { if (performer == null) { throw new NullPointerException("Has to provide a key!"); // NOI18N @@ -70,8 +87,13 @@ implements Action, ContextAwareAction { this.selectMode = selectMode; this.performer = performer; this.global = ContextManager.findManager(actionContext, surviveFocusChange); + this.enableMonitor = enableMonitor; + if (enableMonitor != null) { + LOG.log(Level.FINE, "Setting enable monitor {0}: {1}", new Object[] { + this, enableMonitor} ); + } } - + /** Overrides superclass method, adds delegate description. */ @Override public String toString() { @@ -86,11 +108,49 @@ implements Action, ContextAwareAction { public boolean isEnabled() { assert EventQueue.isDispatchThread(); - boolean r = global.isEnabled(type, selectMode, performer); + boolean r; + if (enableMonitor != null) { + r = fetchEnabledValue(); + } else { + r = global.isEnabled(type, selectMode, performer); + } previousEnabled = r; return r; } + + private boolean fetchEnabledValue() { + return global.runEnabled(type, selectMode, (all, everything) -> { + Supplier af = () -> (Action)performer.delegate0(everything, all, true); + if (enableMonitor.getType() == Action.class) { + // special case for monitoring the action itself + Action dele = (Action)performer.delegate(everything, all); + // delegate to the action + return enableMonitor.enabled(Collections.singletonList(dele), () -> dele); + } else if (enableMonitor.getType() != type) { + return global.runEnabled(enableMonitor.getType(), selectMode, + (all2, everything2) -> { + // run enable monitor for the other type and the original action + return enableMonitor.enabled(all2, af); + } + ); + } else { + return enableMonitor.enabled(all, af); + } + }); + } + + @Override + public void stateChanged(ChangeEvent e) { + // remap PropertyMonitor change events into EDT + Mutex.EVENT.readAccess(this); + } + + @Override + public void run() { + updateStateProperties(); + } + @Override public synchronized void addPropertyChangeListener(PropertyChangeListener listener) { boolean first = false; if (support== null) { @@ -99,19 +159,37 @@ implements Action, ContextAwareAction { } support.addPropertyChangeListener(listener); if (first) { - global.registerListener(type, this); + startListeners(); } } + @Override public synchronized void removePropertyChangeListener(PropertyChangeListener listener) { if( null != support ) { support.removePropertyChangeListener(listener); if (!support.hasListeners(null)) { - global.unregisterListener(type, this); + stopListeners(); support = null; } } } + + protected void startListeners() { + performer.startListeners(); + global.registerListener(type, this); + if (enableMonitor != null) { + fetchEnabledValue(); + enableMonitor.addChangeListener(this); + } + } + + protected void stopListeners() { + global.unregisterListener(type, this); + performer.stopListeners(); + if (enableMonitor != null) { + enableMonitor.removeChangeListener(this); + } + } public void putValue(String key, Object o) { } @@ -121,28 +199,79 @@ implements Action, ContextAwareAction { // special API to support re-enablement assert EventQueue.isDispatchThread(); updateState(); + } else if (ACTION_COMMAND_KEY.equals(key)) { + Object o = performer.delegate.get(ACTION_COMMAND_KEY); + if (o == null) { + o = performer.delegate.get("key"); // NOI18N + } + if (o != null) { + return o.toString(); + } } return null; } public void setEnabled(boolean b) { } + + void clearState() { + performer.clear(); + if (enableMonitor != null) { + enableMonitor.clear(); + } + } + /** + * Called from context manager, when the objects watched for in + * Lookup change. + */ void updateState() { + clearState(); + if (!isListening()) { + return; + } + updateStateProperties(); + } + + void updateStateProperties() { + boolean prev = previousEnabled; + boolean now = isEnabled(); + if (prev != now) { + updateEnabledState(now); + } + } + + boolean wasEnabled() { + return previousEnabled; + } + + protected boolean isListening() { + synchronized (this) { + return support != null; + } + } + + protected void firePropertyChange(String property, Boolean old, Boolean current) { PropertyChangeSupport s; synchronized (this) { s = support; + if (s == null) { + return; + } } - boolean prev = previousEnabled; - if (s != null && prev != isEnabled()) { - s.firePropertyChange("enabled", Boolean.valueOf(prev), Boolean.valueOf(!prev)); // NOI18N - } + s.firePropertyChange(property, old, current); + } + + protected void updateEnabledState(boolean enabled) { + this.previousEnabled = enabled; + firePropertyChange("enabled", !enabled, enabled); // NOI18N } /** Clones itself with given context. */ public Action createContextAwareInstance(Lookup actionContext) { - return new ContextAction(performer, selectMode, actionContext, type, global.isSurvive()); + return new ContextAction(performer, selectMode, actionContext, type, global.isSurvive(), + enableMonitor == null ? null : enableMonitor.createContextMonitor(actionContext)); } @Override @@ -168,10 +297,17 @@ implements Action, ContextAwareAction { } return false; } - - static class Performer { + + private static final Reference NONE = new WeakReference<>(null); + + static class Performer implements ChangeListener { final Map delegate; - + Reference owner; + Reference instDelegate = null; + StatefulMonitor enabler = null; + ChangeListener weakEnableListener; + PropertyChangeListener weakActionListener; + public Performer(Map delegate) { this.delegate = delegate; } @@ -181,36 +317,90 @@ implements Action, ContextAwareAction { ContextActionEnabler e ) { Map map = new HashMap(); - map.put("delegate", p); - map.put("enabler", e); + map.put("delegate", p); // NOI18N + map.put("enabler", e); // NOI18N this.delegate = map; } - - @SuppressWarnings("unchecked") - public void actionPerformed( - ActionEvent ev, List data, Lookup.Provider everything - ) { - Object obj = delegate.get("delegate"); // NOI18N - if (obj instanceof ContextActionPerformer) { - ContextActionPerformer perf = (ContextActionPerformer)obj; - perf.actionPerformed(ev, data); - return; + + void clear() { + stopListeners(); + Reference r = instDelegate; + instDelegate = null; + if (r != null) { + Object o = r.get(); + if (o instanceof Performer) { + ((Performer)o).clear(); + } } - if (obj instanceof Performer) { - Performer perf = (Performer)obj; - perf.actionPerformed(ev, data, everything); - return; + } + + void attach(ContextAction a) { + this.owner = new WeakReference<>(a); + } + + /** + * Creates a delegate. + * @param everything + * @param data + * @return + */ + Object delegate(Lookup.Provider everything, List data) { + return delegate0(everything, data, true); + } + + private Object delegate0(Lookup.Provider everything, List data, boolean getAction) { + Object d = instDelegate != null ? instDelegate.get() : null; + if (d != null) { + if (getAction && (d instanceof Performer)) { + return ((Performer)d).delegate0(everything, data, getAction); + } + return d; } - if (obj instanceof ContextAwareAction) { - Action a = ((ContextAwareAction)obj).createContextAwareInstance(everything.getLookup()); - a.actionPerformed(ev); - return; + d = createDelegate(everything, data); + if (d != null) { + if (getAction && (d instanceof Performer)) { + final Object fd = d; + instDelegate = new WeakReference(d) { private Object hardRef = fd; }; + return ((Performer)d).delegate0(everything, data, getAction); + } + if (d instanceof ContextAwareAction) { + d = ((ContextAwareAction)d).createContextAwareInstance(everything.getLookup()); + } + instDelegate = new WeakReference<>(d); + } else { + instDelegate = NONE; } - - GeneralAction.LOG.warning("No 'delegate' for " + delegate); // NOI18N + return d; } + + void stopListeners() { + if (enabler != null) { + enabler.removeChangeListener(weakEnableListener); + weakEnableListener = null; + } + } + + void startListeners() { + if (enabler != null) { + weakEnableListener = WeakListeners.change(this, enabler); + enabler.addChangeListener(weakEnableListener); + } + } + + /** + * Called when the manager decides that the action should not be enabled at all. + * The Performer should detach from the delegate and enabler. + */ + void detach() { + stopListeners(); + Object inst = instDelegate != null ? instDelegate.get() : null; + if (inst instanceof Action) { + ((Action)inst).removePropertyChangeListener(weakActionListener); + } + } + @SuppressWarnings("unchecked") - public boolean enabled(List data) { + public boolean enabled(List data, Lookup.Provider everything) { Object o = delegate.get("enabler"); // NOI18N if (o == null) { return true; @@ -224,7 +414,41 @@ implements Action, ContextAwareAction { GeneralAction.LOG.warning("Wrong enabler for " + delegate + ":" + o); return false; } + + protected Object createDelegate(Lookup.Provider everything, List data) { + Object obj = delegate.get("delegate"); // NOI18N + if (obj instanceof ContextActionPerformer) { + return obj; + } + if (obj instanceof Performer) { + return obj; + } + if (!(obj instanceof ActionListener)) { + GeneralAction.LOG.warning("Wrong delegate for " + delegate + ":" + obj); + } + return obj; + } + @SuppressWarnings("unchecked") + public void actionPerformed( + ActionEvent ev, List data, Lookup.Provider everything + ) { + Object obj = delegate0(everything, data, false); + if (obj instanceof ContextActionPerformer) { + ContextActionPerformer perf = (ContextActionPerformer)obj; + perf.actionPerformed(ev, data); + return; + } + if (obj instanceof Performer) { + Performer perf = (Performer)obj; + perf.actionPerformed(ev, data, everything); + return; + } + if (obj instanceof ActionListener) { + ((ActionListener)obj).actionPerformed(ev); + } + } + @Override public int hashCode() { return delegate.hashCode() + 117; @@ -241,7 +465,47 @@ implements Action, ContextAwareAction { } return false; } + + @Override + public void stateChanged(ChangeEvent e) { + ContextAction a = owner.get(); + if (a != null) { + a.updateState(); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + Object o = delegate.get(ACTION_COMMAND_KEY); + if (o == null) { + o = delegate.get("key"); // NOI18N + } + Object d = instDelegate == null ? null : instDelegate.get(); + sb.append("Performer{id = ").append(Objects.toString(o)) + .append(", del = ").append(Objects.toString(d)) + .append("}"); + return sb.toString(); + } + } + + /** + * Interface between Performer and value monitors. + * @param + */ + static interface StatefulMonitor { + public void clear(); + public void addChangeListener(ChangeListener l); + public void removeChangeListener(ChangeListener l); + /** + * Factory interface allows first to evaluate guard conditions, then + * query action; delays action creation. + */ + public boolean enabled(List data, Supplier actionFactory); + public Class getType(); + public StatefulMonitor createContextMonitor(Lookup context); } + } diff --git a/openide.awt/src/org/openide/awt/ContextManager.java b/openide.awt/src/org/openide/awt/ContextManager.java index 256b973..6ec2a8d 100644 --- a/openide.awt/src/org/openide/awt/ContextManager.java +++ b/openide.awt/src/org/openide/awt/ContextManager.java @@ -32,8 +32,10 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiFunction; import java.util.logging.Level; import java.util.logging.Logger; +import javax.swing.Action; import org.openide.util.Lookup; import org.openide.util.Lookup.Item; import org.openide.util.Lookup.Provider; @@ -137,13 +139,32 @@ class ContextManager extends Object { Lookup.Result result = findResult(type); boolean e = isEnabledOnData(result, type, selectMode); - if (e && enabler != null) { - e = enabler.enabled(listFromResult(result)); + if (enabler != null) { + if (e) { + List all = listFromResult(result); + e = enabler.enabled(all, new LkpAE(all, type)); + } else if (enabler != null) { + enabler.detach(); + } } return e; } + /** Checks whether a type is enabled. + */ + public boolean runEnabled(Class type, ContextSelection selectMode, BiFunction, Lookup.Provider, Boolean> callback) { + Lookup.Result result = findResult(type); + + boolean e = isEnabledOnData(result, type, selectMode); + if (e) { + List all = listFromResult(result); + e = callback.apply(all, new LkpAE(all, type)); + } + + return e; + } + private boolean isEnabledOnData(Lookup.Result result, Class type, ContextSelection selectMode) { boolean res = isEnabledOnDataImpl(result, type, selectMode); LOG.log(Level.FINE, "isEnabledOnData(result, {0}, {1}) = {2}", new Object[]{type, selectMode, res}); @@ -215,24 +236,31 @@ class ContextManager extends Object { return res; } + private final class LkpAE implements Lookup.Provider { + final List all; + final Class type; + public LkpAE(List all, Class type) { + this.all = all; + this.type = type; + } + + private Lookup lookup; + public Lookup getLookup() { + if (lookup == null) { + lookup = new ProxyLookup( + Lookups.fixed(all.toArray()), + Lookups.exclude(ContextManager.this.lookup, type) + ); + } + return lookup; + } + } + public void actionPerformed(final ActionEvent e, ContextAction.Performer perf, final Class type, ContextSelection selectMode) { Lookup.Result result = findResult(type); final List all = listFromResult(result); - class LkpAE implements Lookup.Provider { - private Lookup lookup; - public Lookup getLookup() { - if (lookup == null) { - lookup = new ProxyLookup( - Lookups.fixed(all.toArray()), - Lookups.exclude(ContextManager.this.lookup, type) - ); - } - return lookup; - } - } - - perf.actionPerformed(e, Collections.unmodifiableList(all), new LkpAE()); + perf.actionPerformed(e, Collections.unmodifiableList(all), new LkpAE(all, type)); } private List listFromResult(Lookup.Result result) { diff --git a/openide.awt/src/org/openide/awt/GeneralAction.java b/openide.awt/src/org/openide/awt/GeneralAction.java index 815de55..960caac 100644 --- a/openide.awt/src/org/openide/awt/GeneralAction.java +++ b/openide.awt/src/org/openide/awt/GeneralAction.java @@ -19,22 +19,28 @@ package org.openide.awt; +import java.awt.Component; import java.awt.EventQueue; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.util.HashMap; import java.util.Map; +import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.Action; import javax.swing.ActionMap; +import javax.swing.JMenuItem; import org.openide.awt.ContextAction.Performer; +import org.openide.awt.ContextAction.StatefulMonitor; import org.openide.util.ContextAwareAction; import org.openide.util.Lookup; import org.openide.util.Parameters; import org.openide.util.Utilities; import org.openide.util.WeakListeners; import org.openide.util.actions.ActionInvoker; +import org.openide.util.actions.ActionPresenterProvider; +import org.openide.util.actions.Presenter; /** * @@ -74,24 +80,76 @@ final class GeneralAction { Lookup context, Class dataType ) { - return new ContextAction(perf, selectionType, context, dataType, false); + return new ContextAction(perf, selectionType, context, dataType, false, null); } public static ContextAwareAction context(Map map) { + return context(map, false); + } + + static ContextAwareAction context(Map map, boolean instanceReady) { Class dataType = readClass(map.get("type")); // NOI18N - return new DelegateAction(map, _context(map, dataType, Utilities.actionsGlobalContext())); + ContextAwareAction ca = _context(map, dataType, Utilities.actionsGlobalContext(), instanceReady); + // autodetect on/off actions + if (ca.getValue(Action.SELECTED_KEY) != null) { + return new StateDelegateAction(map, ca); + } else { + return new DelegateAction(map, ca); + } } + public static Action bindContext(Map map, Lookup context) { Class dataType = readClass(map.get("type")); // NOI18N - return new BaseDelAction(map, _context(map, dataType, context)); + return new BaseDelAction(map, _context(map, dataType, context, false)); } - private static ContextAwareAction _context(Map map, Class dataType, Lookup context) { + + private static ContextAwareAction _context(Map map, Class dataType, Lookup context, boolean instanceReady) { ContextSelection sel = readSelection(map.get("selectionType")); // NOI18N Performer perf = new Performer(map); boolean survive = Boolean.TRUE.equals(map.get("surviveFocusChange")); // NOI18N - return new ContextAction( - perf, sel, context, dataType, survive - ); + StatefulMonitor enableMonitor = null; + StatefulMonitor checkMonitor = null; + Class enableType = tryReadClass(map.get("enableOnType")); + if (enableType == null) { + enableType = dataType; + } + Object del = map.get("enableOnActionProperty"); + Object o = map.get("enableOnProperty"); // NOI18N + + if (o instanceof String || (o == null && (del instanceof String))) { + enableMonitor = new PropertyMonitor(enableType, (String)o, "enableOn", map); + } + o = map.get("checkedOnProperty"); // NOI18N + if (o instanceof String) { + Class c = tryReadClass(map.get("checkedOnType")); // NOI18N + if (c != null) { + checkMonitor = new PropertyMonitor(c, (String)o, "checkedOn", map); + } + } + // special case to hook on existing action instances + if (instanceReady) { // NOI18N + enableMonitor = new PropertyMonitor(Action.class, "enabled"); // NOI18N + Object ao = map.get("delegate"); + if (ao instanceof Action) { + if (((Action)ao).getValue(Action.SELECTED_KEY) != null) { + checkMonitor = new PropertyMonitor(Action.class, Action.SELECTED_KEY); + } + } + } + + ContextAction a; + + if (checkMonitor == null) { + a = new ContextAction( + perf, sel, context, dataType, survive, enableMonitor + ); + } else { + a = new StatefulAction<>(perf, sel, context, dataType, survive, enableMonitor, checkMonitor); + LOG.log(Level.FINE, "Created stateful delegate for {0}, instance {1}, value monitor {2}", + new Object[] { map, a, checkMonitor }); + } + + return a; } private static ContextSelection readSelection(Object obj) { @@ -103,7 +161,16 @@ final class GeneralAction { } throw new IllegalStateException("Cannot parse 'selectionType' value: " + obj); // NOI18N } - private static Class readClass(Object obj) { + + static Class readClass(Object obj) { + Class r = tryReadClass(obj); + if (r == null) { + throw new IllegalStateException("Cannot read 'type' value: " + obj); // NOI18N + } + return r; + } + + static Class tryReadClass(Object obj) { if (obj instanceof Class) { return (Class)obj; } @@ -121,7 +188,7 @@ final class GeneralAction { throw new IllegalStateException(ex); } } - throw new IllegalStateException("Cannot read 'type' value: " + obj); // NOI18N + return null; } static final Object extractCommonAttribute(Map fo, Action action, String name) { return AlwaysEnabledAction.extractCommonAttribute(fo, name); @@ -146,6 +213,81 @@ final class GeneralAction { } } // end of DelegateAction + /** + * Specialization that handles {@link #SELECTED_KEY} action value. Delegats to either the {@link #fallback} or the + * action delegated to by the {@link #key}. Uses toggle button as Toolbar presenter and checkbox as menu presenter. + */ + static final class StateDelegateAction extends BaseDelAction implements ContextAwareAction, + Presenter.Toolbar, Presenter.Menu, Presenter.Popup, PropertyChangeListener { + + public StateDelegateAction(Map map, Object key, Lookup actionContext, Action fallback, boolean surviveFocusChange, boolean async) { + super(map, key, actionContext, fallback, surviveFocusChange, async); + putValue(SELECTED_KEY, fallback.getValue(SELECTED_KEY)); + } + + public StateDelegateAction(Map map, Action fallback) { + super(map, fallback); + } + + @Override + public Component getToolbarPresenter() { + return ActionPresenterProvider.getDefault().createToolbarPresenter(this); + } + + @Override + public JMenuItem getMenuPresenter() { + return ActionPresenterProvider.getDefault().createMenuPresenter(this); + } + + @Override + public JMenuItem getPopupPresenter() { + return ActionPresenterProvider.getDefault().createPopupPresenter(this); + } + + @Override + void updateState(ActionMap prev, ActionMap now, boolean fire) { + super.updateState(prev, now, fire); + if (key == null) { + return; + } + Action pa = prev.get(key); + Action na = now.get(key); + if (pa == na) { + return; + } + Boolean os; + Boolean ns; + if (pa != null) { + os = Boolean.TRUE.equals(pa.getValue(SELECTED_KEY)); + } else { + os = Boolean.TRUE.equals(fallback.getValue(SELECTED_KEY)); + } + if (na != null) { + ns = Boolean.TRUE.equals(na.getValue(SELECTED_KEY)); + } else { + ns = Boolean.TRUE.equals(fallback.getValue(SELECTED_KEY)); + } + if (os != ns) { + putValue(SELECTED_KEY, ns); + } + } + + + @Override + public void propertyChange(PropertyChangeEvent evt) { + super.propertyChange(evt); + if (SELECTED_KEY.equals(evt.getPropertyName())) { + Object o = evt.getNewValue(); + putValue(SELECTED_KEY, o != null ? fallback.getValue(SELECTED_KEY) : o); + } + } + + @Override + protected BaseDelAction copyDelegate(Action f, Lookup actionContext) { + return new StateDelegateAction(map, key, actionContext, f, global.isSurvive(), async); + } + } + static class BaseDelAction extends Object implements Action, PropertyChangeListener { /** file object, if we are associated to any */ @@ -164,7 +306,7 @@ final class GeneralAction { private PropertyChangeSupport support; /** listener to check listen on state of action(s) we delegate to */ - final PropertyChangeListener weakL; + PropertyChangeListener weakL; Map attrs; /** Constructs new action that is bound to given context and @@ -179,6 +321,9 @@ final class GeneralAction { this.weakL = WeakListeners.propertyChange(this, fallback); this.async = async; if (fallback != null) { + LOG.log(Level.FINER, "Action {0}: Attaching propchange to {1}", new Object[] { + this, fallback + }); fallback.addPropertyChangeListener(weakL); } } @@ -197,7 +342,7 @@ final class GeneralAction { /** Overrides superclass method, adds delegate description. */ @Override public String toString() { - return super.toString() + "[key=" + key + "]"; // NOI18N + return super.toString() + "[key=" + key + ", map=" + map + "]"; // NOI18N } /** Invoked when an action occurs. @@ -218,32 +363,45 @@ final class GeneralAction { public synchronized void addPropertyChangeListener(PropertyChangeListener listener) { boolean first = false; - if (support== null) { + if (support == null) { support = new PropertyChangeSupport(this); first = true; } support.addPropertyChangeListener(listener); if (first) { + LOG.log(Level.FINER, "Action {0}: Adding global listener for key {1}", new Object[]{this, key}); global.registerListener(key, this); } } public synchronized void removePropertyChangeListener(PropertyChangeListener listener) { - if( support != null ) { + if (support != null) { support.removePropertyChangeListener(listener); if (!support.hasListeners(null)) { global.unregisterListener(key, this); + LOG.log(Level.FINER, "Action {0}: Removed global listener for key {1}", new Object[]{this, key}); support = null; } } } - public void putValue(String key, Object value) { if (attrs == null) { attrs = new HashMap(); } + PropertyChangeSupport s; + + synchronized (this) { + s = support; + } + Object old = null; + if (s != null) { + old = getValue(key); + } attrs.put(key, value); + if (s != null) { + s.firePropertyChange(key, old, old != null ? value : null); + } } public Object getValue(String key) { @@ -299,6 +457,10 @@ final class GeneralAction { return a == null ? fallback : a; } + protected BaseDelAction copyDelegate(Action f, Lookup actionContext) { + return new DelegateAction(map, key, actionContext, f, global.isSurvive(), async); + } + /** Clones itself with given context. */ public Action createContextAwareInstance(Lookup actionContext) { @@ -306,7 +468,7 @@ final class GeneralAction { if (f instanceof ContextAwareAction) { f = ((ContextAwareAction)f).createContextAwareInstance(actionContext); } - DelegateAction other = new DelegateAction(map, key, actionContext, f, global.isSurvive(), async); + BaseDelAction other = copyDelegate(f, actionContext); if (attrs != null) { other.attrs = new HashMap(attrs); } @@ -315,6 +477,7 @@ final class GeneralAction { public void propertyChange(PropertyChangeEvent evt) { if ("enabled".equals(evt.getPropertyName())) { // NOI18N + LOG.log(Level.FINE, "Action {0}: got property change from fallback {1}", new Object[] { this, fallback }); PropertyChangeSupport sup; synchronized (this) { sup = support; diff --git a/openide.awt/src/org/openide/awt/InjectorAny.java b/openide.awt/src/org/openide/awt/InjectorAny.java index 07f8018..e6f1e6f 100644 --- a/openide.awt/src/org/openide/awt/InjectorAny.java +++ b/openide.awt/src/org/openide/awt/InjectorAny.java @@ -19,11 +19,12 @@ package org.openide.awt; -import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.lang.reflect.Constructor; import java.util.List; import java.util.Map; +import java.util.Objects; +import static javax.swing.Action.ACTION_COMMAND_KEY; import org.openide.util.Exceptions; import org.openide.util.Lookup; import org.openide.util.Lookup.Provider; @@ -34,7 +35,7 @@ final class InjectorAny extends ContextAction.Performer { } @Override - public void actionPerformed(ActionEvent ev, List data, Provider everything) { + protected Object createDelegate(Provider everything, List data) { String clazz = (String) delegate.get("injectable"); // NOI18N ClassLoader l = Lookup.getDefault().lookup(ClassLoader.class); if (l == null) { @@ -46,10 +47,27 @@ final class InjectorAny extends ContextAction.Performer { try { Class clazzC = Class.forName(clazz, true, l); Constructor c = clazzC.getConstructor(List.class); - ActionListener action = (ActionListener) c.newInstance(data); - action.actionPerformed(ev); + return (ActionListener) c.newInstance(data); } catch (Exception ex) { - Exceptions.printStackTrace(ex); - } + Exceptions.printStackTrace(ex); } + return null; } + + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + Object o = delegate.get("key"); // NOI18N + if (o == null) { + o = delegate.get(ACTION_COMMAND_KEY); + } + Object d= instDelegate == null ? null : instDelegate.get(); + sb.append("PerformerANY{id = ").append(Objects.toString(o)) + .append(", del = ").append(Objects.toString(d)) + .append(", injectable = ").append(delegate.get("injectable")) + .append(", type = ").append(delegate.get("type")) + .append("}"); + return sb.toString(); + } +} diff --git a/openide.awt/src/org/openide/awt/InjectorExactlyOne.java b/openide.awt/src/org/openide/awt/InjectorExactlyOne.java index 42cdfc6..7597e6a 100644 --- a/openide.awt/src/org/openide/awt/InjectorExactlyOne.java +++ b/openide.awt/src/org/openide/awt/InjectorExactlyOne.java @@ -19,11 +19,12 @@ package org.openide.awt; -import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.lang.reflect.Constructor; import java.util.List; import java.util.Map; +import java.util.Objects; +import static javax.swing.Action.ACTION_COMMAND_KEY; import org.openide.util.Exceptions; import org.openide.util.Lookup; import org.openide.util.Lookup.Provider; @@ -34,9 +35,9 @@ final class InjectorExactlyOne extends ContextAction.Performer { } @Override - public void actionPerformed(ActionEvent ev, List data, Provider everything) { + protected Object createDelegate(Provider everything, List data) { if (data.size() != 1) { - return; + return null; } String clazz = (String) delegate.get("injectable"); // NOI18N String type = (String) delegate.get("type"); // NOI18N @@ -52,9 +53,26 @@ final class InjectorExactlyOne extends ContextAction.Performer { Class clazzC = Class.forName(clazz, true, l); Constructor c = clazzC.getConstructor(typeC); ActionListener action = (ActionListener) c.newInstance(data.get(0)); - action.actionPerformed(ev); + return action; } catch (Exception ex) { - Exceptions.printStackTrace(ex); + Exceptions.printStackTrace(ex); + return null; } } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + Object o = delegate.get("key"); // NOI18N + if (o == null) { + o = delegate.get(ACTION_COMMAND_KEY); + } + Object d= instDelegate == null ? null : instDelegate.get(); + sb.append("PerformerONE{id = ").append(Objects.toString(o)) + .append(", del = ").append(Objects.toString(d)) + .append(", injectable = ").append(delegate.get("injectable")) + .append(", type = ").append(delegate.get("type")) + .append("}"); + return sb.toString(); + } } diff --git a/openide.awt/src/org/openide/awt/PropertyMonitor.java b/openide.awt/src/org/openide/awt/PropertyMonitor.java new file mode 100644 index 0000000..7b36d4d --- /dev/null +++ b/openide.awt/src/org/openide/awt/PropertyMonitor.java @@ -0,0 +1,651 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.openide.awt; + +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EventListener; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.Action; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.openide.awt.ContextAction.StatefulMonitor; +import org.openide.util.Exceptions; +import org.openide.util.Lookup; +import org.openide.util.Utilities; +import org.openide.util.WeakListeners; + +/** + * Enabler, which will work against a certain property on a target object. It attaches to the object when it + * changes, and monitors its property's state using either {@link PropertyChangeListener} or {@link ChangeListener}. + * If the monitored property's state changes, it fires a change to alert the owner Action. + * + * @param data type + */ +class PropertyMonitor implements ContextAction.StatefulMonitor, PropertyChangeListener, ChangeListener { + private static final Logger LOG = Logger.getLogger(PropertyMonitor.class.getName()); + + static final String KEY_CHECKED_VALUE = "Value"; // NOI18N + static final String KEY_LISTEN_INTERFACE = "ChangeListener"; // NOI18N + static final String KEY_INTERFACE_METHOD = "Method"; // NOI18N + static final String KEY_CUSTOM_CHECK = "ActionProperty"; // NOI18N + static final String KEY_NULL = "Null"; // NO18N + + /** + * Reflection not initialized + */ + private static final int UNINITIALIZED = -1; + /** + * Listeners not supported + */ + private static final int NONE = 0; + /** + * Property change listener registered against specific property name + */ + private static final int PROPERTY_NAME = 1; + /** + * Listener registered using general add method + */ + private static final int PROPERTY_ALL = 2; + /** + * ChangeListener is used, + */ + private static final int CHANGE = 3; + + /** + * Custom listener interace. + */ + private static final int CUSTOM = 4; + + /** + * Type being monitored, {@link Action} treated specially. + */ + private final Class type; + + /** + * The property being monitored + */ + private final String property; + + /** + * The value which makes the action selected. + */ + private final Object checkedValue; + + private Class valType; + + /** + * Reflective access to the property's value + */ + private Method refGetter; + + /** + * Reflective access to add listener + */ + private Method refAddListener; + + /** + * Reflective access to remove listener + */ + private Method refRemoveListener; + + /** + * Detected listener type + */ + private int listenerType = UNINITIALIZED; + + /** + * The Weak listener attached to the monitored data + */ + private EventListener weakListener; + + /** + * The last data being monitored + */ + private Reference attachedTo; + + /** + * Change Listeners added to this monitor. + */ + private List listeners = null; + + /** + * Listener interface to listen + */ + private Class listenerInterface; + + /** + * Method name to intercept; null for all methods + */ + private final String methodName; + + private final StatefulMonitor actionMonitor; + + private final Function valueFactory; + + public PropertyMonitor(Class type, String property) { + this(type, property, "", Collections.emptyMap()); + } + + public PropertyMonitor(Class type, String property, String keyPrefix, Map data) { + this.type = type; + this.property = property; + + Object cv = data.get(keyPrefix + KEY_CHECKED_VALUE); + if (cv == null) { + Object b= data.get(keyPrefix + KEY_NULL); + if (b instanceof Boolean) { + cv = ((Boolean)b).booleanValue() ? ActionState.NULL_VALUE : ActionState.NON_NULL_VALUE; + } + } + checkedValue = cv; + + valueFactory = initValueAccess(); + + Object o = data.get(keyPrefix + KEY_LISTEN_INTERFACE); + String mn = null; + if (o instanceof String) { + listenerInterface = GeneralAction.readClass(o); + o = data.get(keyPrefix + KEY_INTERFACE_METHOD); + if (o instanceof String) { + mn = (String)o; + } + } + Object customCheck = data.get(keyPrefix + KEY_CUSTOM_CHECK); + if (customCheck != null) { + actionMonitor = new PropertyMonitor(Action.class, customCheck.toString()); + } else { + if (property == null) { + throw new IllegalArgumentException("Delegate or guard property must be specified"); + } + actionMonitor = null; + } + methodName = mn; + } + + public Class getType() { + return type; + } + + private T data() { + synchronized (this) { + return attachedTo != null ? attachedTo.get() : null; + } + } + + public void clear() { + Object o = data(); + if (o != null) { + clearListeners(o); + } + if (actionMonitor != null) { + actionMonitor.clear(); + } + synchronized (this) { + attachedTo = null; + } + } + + public void addChangeListener(ChangeListener l) { + boolean start = false; + synchronized (this) { + if (listeners == null) { + listeners = new ArrayList<>(); + start = true; + } + listeners.add(l); + } + if (start) { + T d = data(); + LOG.log(Level.FINER, "{0}: attaching listener to {1}", new Object[] { this, d }); + if (d != null) { + addListeners(d); + } + } + } + + public void removeChangeListener(ChangeListener l) { + boolean stop = false; + synchronized (this) { + if (listeners == null) { + return; + } + listeners.remove(l); + stop = listeners.isEmpty(); + if (stop) { + listeners = null; + } + } + if (stop) { + T d = data(); + if (d != null) { + clearListeners(d); + } + } + } + + private void clearListeners(Object data) { + if (weakListener == null || refRemoveListener == null) { + return; + } + LOG.log(Level.FINER, "{0}: adding listener to {1}", new Object[] { this, data }); + try { + switch (listenerType) { + case PROPERTY_NAME: + refRemoveListener.invoke(data, property, weakListener); + break; + case CUSTOM: + ((ProxyListener)Proxy.getInvocationHandler(weakListener)).unregister(data); + break; + case PROPERTY_ALL: + case CHANGE: + refRemoveListener.invoke(data, weakListener); + break; + case NONE: + break; + default: + throw new IllegalStateException(); + } + } catch (ReflectiveOperationException | IllegalArgumentException ex) { + ex.printStackTrace(); + } + weakListener = null; + if (actionMonitor != null) { + actionMonitor.removeChangeListener(this); + } + } + + /** + * Initializes listener reflective access. + * @param data + */ + private void initListenerReflection() { + if (listenerType != UNINITIALIZED) { + return; + } + Method add = null; + try { + if (listenerInterface != null) { + add = type.getMethod("add" + listenerInterface.getSimpleName(), listenerInterface); + listenerType = CUSTOM; + } else { + try { + if (property != null) { + add = type.getMethod("addPropertyChangeListener", String.class, PropertyChangeListener.class); + listenerType = PROPERTY_NAME; + } + } catch (NoSuchMethodException ex) { + // expected, ignore + } + if (add == null) { + try { + add = type.getMethod("addPropertyChangeListener", PropertyChangeListener.class); + listenerType = PROPERTY_ALL; + } catch (NoSuchMethodException ex2) { + add = type.getMethod("addChangeListener", ChangeListener.class); + listenerType = CHANGE; + } + } + } + } catch (NoSuchMethodException | SecurityException ex3) { + listenerType = NONE; + return; + } + Method remove = null; + try { + switch (listenerType) { + case PROPERTY_NAME: + remove = type.getMethod("removePropertyChangeListener", String.class, PropertyChangeListener.class); + break; + case PROPERTY_ALL: + remove = type.getMethod("removePropertyChangeListener", PropertyChangeListener.class); + break; + case CHANGE: + remove = type.getMethod("removeChangeListener", ChangeListener.class); + break; + case CUSTOM: + remove = type.getMethod("remove" + listenerInterface.getSimpleName(), listenerInterface); + break; + } + } catch (NoSuchMethodException | SecurityException ex) { + listenerType = -1; + return; + } + refAddListener = add; + refRemoveListener = remove; + } + + // method accessed by reflection + public boolean falseGetter(Object data) { + return false; + } + + // method accessed by reflection + public boolean trueGetter(Object data) { + return true; + } + + private void addListeners(Object data) { + if (weakListener != null || listenerType == NONE) { + return; + } + initListenerReflection(); + synchronized (this) { + if (listeners == null) { + return; + } + } + PropertyChangeListener pcl; + ChangeListener chl; + LOG.log(Level.FINER, "{0}: adding listener to {1}", new Object[] { this, data }); + try { + switch (listenerType) { + case PROPERTY_NAME: + weakListener = pcl = WeakListeners.propertyChange(this, property, data); + refAddListener.invoke(data, property, pcl); + break; + case PROPERTY_ALL: + weakListener = pcl = WeakListeners.propertyChange(this, data); + refAddListener.invoke(data, pcl); + break; + case CHANGE: + weakListener = chl = WeakListeners.change(this, data); + refAddListener.invoke(data, chl); + break; + case NONE: + return; + case CUSTOM: { + ProxyListener pl = new ProxyListener(data, methodName, refRemoveListener, this); + Object o = Proxy.newProxyInstance(listenerInterface.getClassLoader(), new Class[] { listenerInterface, EventListener.class }, pl); + pl.proxy = weakListener = (EventListener)o; + refAddListener.invoke(data, weakListener); + break; + } + + default: + throw new IllegalStateException(); + } + } catch (ReflectiveOperationException | IllegalArgumentException ex) { + listenerType = NONE; + } + + if (actionMonitor != null) { + actionMonitor.addChangeListener(this); + } + } + + private Function initValueAccess() { + Method getter = null; + if (property != null) { + String capitalizedName = Character.toUpperCase(property.charAt(0)) + property.substring(1); + String isGetter = "is" + capitalizedName; // NOI18N + String getGetter = "get" + capitalizedName; // NOI18N + try { + try { + getter = type.getMethod(isGetter); + } catch (NoSuchMethodException ex) { + getter = type.getMethod(getGetter); + } + Class c = getter.getReturnType(); + if (!(c != Boolean.TYPE || c != Boolean.class || c != String.class || !c.isEnum()) && + !(checkedValue == ActionState.NULL_VALUE || checkedValue == ActionState.NON_NULL_VALUE)) { + getter = null; + } + valType = c; + this.refGetter = getter; + return (o) -> reflectiveGet(o); + } catch (SecurityException | NoSuchMethodException ex) { + } + } + if (type == Action.class) { + return (o) -> inspectAction((Action)o); + } else { + return (o) -> property == null; + } + } + + private void update() { + ChangeListener[] ll; + synchronized (this) { + if (listeners == null) { + return; + } + ll = listeners.toArray(new ChangeListener[listeners.size()]); + } + ChangeEvent ev = new ChangeEvent(this); + for (ChangeListener l : ll) { + l.stateChanged(ev); + } + } + + private void refreshListeners(T data) { + Object prevData = data(); + if (prevData == data) { + return; + } + if (actionMonitor != null) { + actionMonitor.clear(); + } + if (prevData != null) { + clearListeners(prevData); + } + if (data != null) { + addListeners(data); + } + attachedTo = new WeakReference<>(data); + } + + private Object reflectiveGet(Object instance) { + try { + return refGetter.invoke(instance); + } catch (ReflectiveOperationException | IllegalArgumentException ex) { + return false; + } + } + + public boolean enabled(List data, Supplier aFactory) { + T first = data.isEmpty() ? null : data.get(0); + if (data.isEmpty()) { + return false; + } + refreshListeners(first); + if (first == null) { + return false; + } + if (type == Action.class) { + return inspectAction((Action)first); + } + + Object o = valueFactory.apply(first); + if (!interpretAsBoolean(o)) { + return false; + } + if (aFactory != null && actionMonitor != null) { + return actionMonitor.enabled(Collections.singletonList(aFactory.get()), null); + } else { + return true; + } + } + + public boolean inspectAction(Action a) { + if (a == null) { + return false; + } + if ("enabled".equals(property)) { // NOI18N + return a.isEnabled(); + } + return a.getValue(property) == Boolean.TRUE; + } + + private boolean interpretAsBoolean(Object v) { + if (v == null) { + if (checkedValue == ActionState.NULL_VALUE) { + return true; + } + return false; + } + if (valType == null || valType == Boolean.TYPE || valType == Boolean.class) { + if (checkedValue == null) { + return Boolean.TRUE.equals(v); + } else { + return checkedValue.equals(v.toString()); + } + } + if (checkedValue == null) { + return false; + } + if (checkedValue == ActionState.NON_NULL_VALUE) { + return true; + } + if (!(checkedValue instanceof String)) { + return checkedValue.equals(v); + } + return checkedValue.equals(v.toString()); + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + if (evt.getPropertyName() != null && property != null && !property.equals(evt.getPropertyName())) { + return; + } + update(); + } + + @Override + public void stateChanged(ChangeEvent e) { + update(); + } + + public PropertyMonitor(PropertyMonitor other) { + this.type = other.type; + this.property = other.property; + this.checkedValue = other.checkedValue; + this.listenerType = other.listenerType; + this.refGetter = other.refGetter; + this.valueFactory = other.valueFactory; + this.refAddListener = other.refAddListener; + this.refRemoveListener = other.refRemoveListener; + this.listenerInterface = other.listenerInterface; + this.methodName = other.methodName; + if (other.actionMonitor == null) { + this.actionMonitor = null; + } else { + this.actionMonitor = other.actionMonitor.createContextMonitor(Lookup.EMPTY); + } + } + + @Override + public ContextAction.StatefulMonitor createContextMonitor(Lookup context) { + return new PropertyMonitor<>(this); + } + + private static final Method OBJECT_EQUALS = getObjectMethod("equals", Object.class); // NOI18N + private static final Method OBJECT_HASHCODE = getObjectMethod("hashCode"); // NOI18N + + private static Method getObjectMethod(String name, Class... types) { + try { + return Object.class.getMethod(name, types); + } catch (ReflectiveOperationException | SecurityException ex) { + throw new IllegalStateException(ex); + } + } + + private static class ProxyListener extends WeakReference implements EventListener, InvocationHandler, Runnable { + private final Reference theData; + private final String methodName; + private final Method removeMethod; + volatile EventListener proxy; + + public ProxyListener(Object theData, String methodName, Method removeMethod, ChangeListener referent) { + super(referent, Utilities.activeReferenceQueue()); + this.theData = new WeakReference<>(theData); + this.methodName = methodName; + this.removeMethod = removeMethod; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (method.getDeclaringClass() == Object.class) { + // a method from object => call it on your self + if (method == OBJECT_EQUALS) { + return equals(args[0]); + } else if (method == OBJECT_HASHCODE) { + return proxy.hashCode(); + } + return method.invoke(this, args); + } + ChangeListener target = get(); + Object data = theData.get(); + if (data == null) { + return null; + } + if (target == null) { + return null; + } + if (methodName == null || method.getName().equals(methodName)) { + ChangeEvent ev = new ChangeEvent(data); + target.stateChanged(ev); + } + return null; + } + + private void unregister(Object data) { + if (data == null) { + return; + } + if (removeMethod != null) { + try { + removeMethod.invoke(data, proxy); + } catch (ReflectiveOperationException | SecurityException ex) { + Exceptions.printStackTrace(ex); + } + } + theData.clear(); + } + + @Override + public void run() { + unregister(theData.get()); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("PropertyMonitor@").append(System.identityHashCode(this)).append("{") + .append("class = ").append(type.getName()) + .append(", property = ").append(property) + .append(", valtype = ").append(valType == null ? "null" : valType.getName()) + .append(", checkval = ").append(checkedValue) + .append("}"); + return sb.toString(); + } +} diff --git a/openide.awt/src/org/openide/awt/StatefulAction.java b/openide.awt/src/org/openide/awt/StatefulAction.java new file mode 100644 index 0000000..8c2cd1f --- /dev/null +++ b/openide.awt/src/org/openide/awt/StatefulAction.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.openide.awt; + +import java.util.Collections; +import java.util.logging.Level; +import javax.swing.Action; +import org.openide.util.Lookup; + +/** + * Represents stateful context-aware action. + * @author sdedic + */ +final class StatefulAction extends ContextAction { + /** + * Monitor for "checked" property + */ + private final StatefulMonitor checkValueMonitor; + + /** + * The last selected value. + */ + private boolean selValue; + + /** + * Tracks first attach + */ + private boolean first = true; + + public StatefulAction(Performer performer, ContextSelection selectMode, Lookup actionContext, Class type, boolean surviveFocusChange, + StatefulMonitor enableMonitor, StatefulMonitor valueMonitor) { + super(performer, selectMode, actionContext, type, surviveFocusChange, enableMonitor); + this.checkValueMonitor = valueMonitor; + } + + @Override + void updateStateProperties() { + super.updateStateProperties(); + if (!wasEnabled()) { + LOG.log(Level.FINE, "Action {0} disabled, unchecked", this); + putValue(SELECTED_KEY, false); + return; + } + boolean nowState = fetchStateValue(); + boolean oldState = this.selValue; + this.selValue = nowState; + LOG.log(Level.FINE, "Action {0}: old check state {1}, new check state {2}", new Object[] { + this, oldState, nowState + }); + firePropertyChange(SELECTED_KEY, oldState, nowState); + } + + private boolean fetchStateValue() { + first = false; + if (checkValueMonitor.getType() == Action.class) { + return global.runEnabled(type, selectMode, (all, everything) -> { + return checkValueMonitor.enabled( + Collections.singletonList(performer.delegate(everything, all)), + () -> (Action)performer.delegate(everything, all)); + }); + } else { + return global.runEnabled(checkValueMonitor.getType(), selectMode, (all, everything) -> { + return checkValueMonitor.enabled(all, () -> (Action)performer.delegate(everything, all)); + }); + } + } + + @Override + public Object getValue(String key) { + if (SELECTED_KEY.equals(key)) { + LOG.log(Level.FINER, "Action {0} state: {1}", new Object[] { + this, selValue + }); + return selValue; + } + return super.getValue(key); + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + StatefulMonitor checkMon = checkValueMonitor.createContextMonitor(actionContext); + StatefulMonitor enableMon = enableMonitor == null ? null : enableMonitor.createContextMonitor(actionContext); + Action a = new StatefulAction<>(performer, + selectMode, + actionContext, + type, + global.isSurvive(), + enableMon, + checkMon); + LOG.log(Level.FINE, "Created context Stateful instance: {0} from {1}, check monitor {2}, enable monitor {3}", new Object[] { + a, this, checkMon, enableMon + }); + return a; + } + + @Override + void clearState() { + super.clearState(); + checkValueMonitor.clear(); + } + + @Override + protected void stopListeners() { + Class c = checkValueMonitor.getType(); + if (c != Action.class) { + global.unregisterListener(checkValueMonitor.getType(), this); + } + checkValueMonitor.removeChangeListener(this); + super.stopListeners(); + } + + @Override + protected void startListeners() { + super.startListeners(); + Class c = checkValueMonitor.getType(); + if (c != Action.class) { + global.registerListener(checkValueMonitor.getType(), this); + } + if (first) { + selValue = fetchStateValue(); + } + checkValueMonitor.addChangeListener(this); + } +} diff --git a/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/ActionProcessorTest.java b/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/ActionProcessorTest.java index 00cf521..0b6456c 100644 --- a/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/ActionProcessorTest.java +++ b/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/ActionProcessorTest.java @@ -48,7 +48,6 @@ import org.openide.filesystems.FileUtil; import org.openide.util.ContextAwareAction; import org.openide.util.lookup.AbstractLookup; import org.openide.util.lookup.InstanceContent; -import static org.junit.Assert.*; /** * @@ -917,5 +916,5 @@ public class ActionProcessorTest extends NbTestCase { fail("B has to be static:\n" + os); } } - + } diff --git a/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/StatefulActionProcessorTest.java b/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/StatefulActionProcessorTest.java new file mode 100644 index 0000000..2c59454 --- /dev/null +++ b/openide.awt/test/unit/src/org/netbeans/modules/openide/awt/StatefulActionProcessorTest.java @@ -0,0 +1,1380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.netbeans.modules.openide.awt; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.io.ByteArrayOutputStream; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.ListSelectionModel; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.ListSelectionListener; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.fail; +import org.netbeans.junit.NbTestCase; +import org.openide.awt.ActionID; +import org.openide.awt.ActionRegistration; +import org.openide.awt.ActionState; +import org.openide.awt.Actions; +import org.openide.util.ContextAwareAction; +import org.openide.util.ContextGlobalProvider; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; +import org.openide.util.Utilities; +import org.openide.util.lookup.AbstractLookup; +import org.openide.util.lookup.InstanceContent; +import org.openide.util.lookup.Lookups; +import org.openide.util.lookup.ProxyLookup; +import org.openide.util.test.AnnotationProcessorTestUtils; +import org.openide.util.test.MockLookup; + +/** + * Checks that stateful action support works as designed. + * + * @author sdedic + */ +public class StatefulActionProcessorTest extends NbTestCase implements ContextGlobalProvider { + MockLookup mockLookup; + static Action instance; + static ActionListener instance2; + static int created; + static ActionEvent received; + + static { + System.setProperty("java.awt.headless", "true"); + } + + public StatefulActionProcessorTest(String n) { + super(n); + MockLookup.init(); + } + + @Override + protected void tearDown() throws Exception { + created = 0; + instance = null; + instance2 = null; + received = null; + + Field f = Utilities.class.getDeclaredField("global"); + f.setAccessible(true); + f.set(null, null); + + super.tearDown(); + } + + private InstanceContent lookupContent; + private AbstractLookup testLookup; + private PL actionLookup = new PL(); + + private static class PL extends ProxyLookup { + void setLookupsAccessor(Lookup... lookups) { + super.setLookups(lookups); + } + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + created = 0; + instance = null; + instance2 = null; + received = null; + + reinitActionLookup(); + ClassLoader l = MockLookup.class.getClassLoader(); + MockLookup.setLookup(Lookups.fixed(this), Lookups.metaInfServices(l), Lookups.singleton(l)); + } + + void reinitActionLookup() { + lookupContent = new InstanceContent(); + testLookup = new AbstractLookup(lookupContent); + actionLookup.setLookupsAccessor(testLookup); + } + + @Override + public Lookup createGlobalContext() { + return actionLookup; + } + + @Override + protected boolean runInEQ() { + return true; + } + + + public static enum EnValue { + ONE, TWO + } + + static interface NonpublicListener { + public void callback(); + } + + public static interface CustomListener { + public void callback(); + } + + public static class ClassListener { + + } + + public static class ActionModel { + boolean boolProp; + boolean bool2Prop; + Boolean boolObjectProp; + EnValue enumProp; + String prop; + Object anyProp; + boolean noneBoolProp; + int intProp; + + PropertyChangeSupport supp = new PropertyChangeSupport(this); + + public boolean getBool2Prop() { + return bool2Prop; + } + + public boolean isBoolProp() { + return boolProp; + } + + public Boolean getBoolObjectProp() { + return boolObjectProp; + } + + public String getProp() { + return prop; + } + + public String getAnyProp() { + return null; + } + + int getIntProp() { + return 0; + } + } + + public static class DefaultActionModel { + boolean boolProp; + boolean bool2Prop; + + PropertyChangeSupport supp = new PropertyChangeSupport(this); + + public boolean isEnabled() { + return boolProp; + } + + public void setEnabled(boolean e) { + this.boolProp = e; + supp.firePropertyChange("enabled", null, null); + + } + public boolean getSwingSelectedKey() { + return bool2Prop; + } + + public void setSwinSelectedKey(boolean s) { + this.bool2Prop = s; + supp.firePropertyChange(Action.SELECTED_KEY, null, null); + } + } + + public static class ActionModel2 extends ActionModel { + public void addPropertyChangeListener(String prop, PropertyChangeListener p) { + supp.addPropertyChangeListener(prop, p); + } + + public void removePropertyChangeListener(String prop, PropertyChangeListener p) { + supp.removePropertyChangeListener(prop, p); + } + } + + public static class ActionModel3 extends ActionModel { + public void addPropertyChangeListener(PropertyChangeListener p) { + supp.addPropertyChangeListener(p); + } + + public void removePropertyChangeListener(PropertyChangeListener p) { + supp.removePropertyChangeListener(prop, p); + } + } + + public static class ActionModel4 extends ActionModel { + List listeners = new ArrayList<>(); + + public void fire() { + ChangeEvent e = new ChangeEvent(this); + for (ChangeListener l : listeners) { + l.stateChanged(e); + } + } + + public void addChangeListener(ChangeListener p) { + listeners.add(p); + } + + public void removeChangeListener(ChangeListener p) { + listeners.remove(p); + } + } + + public static class ActionModel5 extends ActionModel { + List listeners = new ArrayList<>(); + + public void fire() { + for (CustomListener l : listeners) { + l.callback(); + } + } + + public void addCustomListener(CustomListener p) { + listeners.add(p); + } + + public void removeCustomListener(CustomListener p) { + listeners.remove(p); + } + } + + /** + * Checks that without type() the default is used, but the property must be specified + */ + public void testCheckOnTypeNoProperty() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState()) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + assertFalse("Compilation has to fail:\n" + os, r); + if (!os.toString().contains("Property must be specified")) { + fail("Property must be specified:\n" + os); + } + } + + /** + * Checks that missing getter is reported + */ + public void testCheckOnGetterNotExist() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"rumcajs\")) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + assertFalse("Compilation has to fail:\n" + os, r); + if (!os.toString().contains("Property rumcajs not found")) { + fail("Property must be specified:\n" + os); + } + } + + /** + * Property getter must be public + */ + public void testCheckOnNonpublicGetter() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"intProp\")) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + assertFalse("Compilation has to fail:\n" + os, r); + if (!os.toString().contains("must be public")) { + fail("Property must be checked for public access:\n" + os); + } + } + + /** + * Checks that boolean "isXX" getter is found + */ + public void testCheckBooleanGetter1() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"boolProp\")) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + assertTrue("Compilation must be successful", r); + } + + /** + * Checks that boolean getXXX getter is found + */ + public void testCheckBooleanGetter2() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"bool2Prop\")) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + assertTrue("Compilation must be successful", r); + } + + /** + * listenOn type must be an interface + */ + public void testInvalidListenerClass() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import javax.swing.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"prop\", listenOn=ClassListener.class)) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + if (!os.toString().contains("is not an interface")) { + fail("class as listener type must be reported" + os); + } + } + + /** + * listenOn type must be public + */ + public void testNonpublicListenerClass() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import javax.swing.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"prop\", listenOn=NonpublicListener.class)) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + if (!os.toString().contains("is not public")) { + fail("Nonpublic listener type must be reported" + os); + } + } + + /** + * no addXxxxListener is present for the specified type + */ + public void testMissingAddListener() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import javax.swing.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = @ActionState(type = ActionModel2.class, property=\"prop\", listenOn=ChangeListener.class)) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + if (!os.toString().contains("Method addChangeListener not found")) { + fail("Missing add listener must be reported" + os); + } + } + + /** + * issues an error if the specified trigger method does not exist + */ + public void testMissingListenerMethod() throws Exception { + clearWorkDir(); + AnnotationProcessorTestUtils.makeSource(getWorkDir(), "test.A", + "import org.openide.awt.ActionRegistration;\n" + + "import org.openide.awt.ActionReference;\n" + + "import org.openide.awt.ActionState;\n" + + "import org.openide.awt.ActionID;\n" + + "import org.openide.util.actions.Presenter;\n" + + "import java.awt.event.*;\n" + + "import javax.swing.event.*;\n" + + "import java.util.List;\n" + + "import javax.swing.*;\n" + + "import org.netbeans.modules.openide.awt.StatefulActionProcessorTest.*;\n" + + + "public class A {\n" + + " @ActionID(category=\"Tools\",id=\"test.action\")" + + " @ActionRegistration(displayName=\"AAA\", key=\"K\", checkedOn = " + + "@ActionState(type = ActionModel5.class, property=\"prop\", listenOn=CustomListener.class, listenOnMethod=\"bubu\")) " + + " @ActionReference(path=\"manka\", position=11)" + + " " + + " public static class B implements ActionListener {\n" + + " public B(ActionModel mdl) {} \n" + + " public void actionPerformed(ActionEvent e) {}\n" + + " }\n" + + "}\n" + ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + boolean r = AnnotationProcessorTestUtils.runJavac(getWorkDir(), null, getWorkDir(), null, os); + if (!os.toString().contains("does not contain method bubu")) { + fail("Missing listener method must be reported" + os); + } + } + + @ActionID(id = "test.DefEnableAction", category="Foo") + @ActionRegistration(displayName = "TestAction", + enabledOn = @ActionState() + ) + public static class DefEnableAction implements ActionListener { + + public DefEnableAction(DefaultActionModel model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that the "enabled" property is used by default if property is not specified + * @throws Exception + */ + public void testDefaulPropertyEnable() throws Exception { + Action a = Actions.forID("Foo", "test.DefEnableAction"); + assertFalse(a.isEnabled()); + + + DefaultActionModel mod = new DefaultActionModel(); + lookupContent.add(mod); + + assertFalse(a.isEnabled()); + + mod.setEnabled(true); + assertTrue(a.isEnabled()); + } + + public static class NonNullModel { + Collection prop1; + + PropertyChangeSupport supp = new PropertyChangeSupport(this); + + public Collection getProp1() { + return prop1; + } + + public void setProp1(Collection c) { + prop1 = c; + supp.firePropertyChange("prop1", null, null); + } + } + + @ActionID(id = "test.NonNull", category="Foo") + @ActionRegistration(displayName = "TestAction", + enabledOn = @ActionState(property = "prop1", checkedValue = ActionState.NON_NULL_VALUE) + ) + public static class NonNullAction implements ActionListener { + + public NonNullAction(NonNullModel model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + @ActionID(id = "test.Null", category="Foo") + @ActionRegistration(displayName = "TestAction", + enabledOn = @ActionState(property = "prop1", checkedValue = ActionState.NULL_VALUE) + ) + public static class NullAction extends NonNullAction { + + public NullAction(NonNullModel model) { + super(model); + } + } + + /** + * Checks that the action enables on null property value + */ + public void testEnableOnNull() throws Exception { + Action a = Actions.forID("Foo", "test.Null"); + assertNotNull(a); + assertFalse(a.isEnabled()); + + // now provide model with non-null set up already + NonNullModel mdl = new NonNullModel(); + + lookupContent.add(mdl); + + assertTrue("Must be enabled after model with property arrives", a.isEnabled()); + + mdl.setProp1(new ArrayList<>()); + assertFalse("Must disable when property becomes null", a.isEnabled()); + } + + /** + * Checks that the action enables on non-null property value + */ + public void testEnableOnNonNull() throws Exception { + Action a = Actions.forID("Foo", "test.NonNull"); + assertNotNull(a); + assertFalse(a.isEnabled()); + + // now provide model with non-null set up already + NonNullModel mdl = new NonNullModel(); + mdl.setProp1(new ArrayList<>()); + + lookupContent.add(mdl); + + assertTrue("Must be enabled after model with property arrives", a.isEnabled()); + + mdl.setProp1(null); + assertFalse("Must disable when property becomes null", a.isEnabled()); + } + + @NbBundle.Messages({ + "TestAction=Test action" + }) + @ActionID(id = "test.InstAction", category="Foo") + @ActionRegistration(displayName = "#TestAction", + enabledOn = @ActionState(property = "boolProp") + ) + public static class InstAction implements ActionListener { + public InstAction(ActionModel3 model) { + created++; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that the stateful action is instantiated only just before it is + * invoked. Enablement should be evaluated by the framework without any + * user code loaded. + */ + public void testEnableActionInstantiation() { + assertEquals("Not pre-created", 0, created); + Action a = Actions.forID("Foo", "test.InstAction"); + assertNotNull(a); + assertEquals("Not direcly created from layer", 0, created); + + assertFalse("No data in lookup", a.isEnabled()); + assertEquals("Not created unless data present", 0, created); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + + // still not enabled + assertFalse("Property not sets", a.isEnabled()); + assertEquals("Not created unless guard is set", 0, created); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + assertTrue("Property not set", a.isEnabled()); + assertEquals("Not created before invocation", 0, created); + + a.actionPerformed(new ActionEvent(this, 0, "cmd")); + assertEquals("Not created before invocation", 1, created); + assertNotNull(received); + assertEquals("cmd", received.getActionCommand()); + } + + /** + * Checks that an action model is freed, after the actionPerformed is called, + * and then the focus shifts so the model is not in Lookup. + */ + public void testActionModelFreed() { + Action a = Actions.forID("Foo", "test.InstAction"); + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + + a.actionPerformed(new ActionEvent(this, 0, "cmd")); + + reinitActionLookup(); + + Reference r = new WeakReference(mod); + mod = null; + assertGC("Action model must be GCed", r); + } + + @ActionID(id = "test.CustomEnableAction", category="Foo") + @ActionRegistration(displayName = "TestAction", + enabledOn = @ActionState(property = "boolProp", useActionInstance = true) + ) + public static class CustomEnableAction extends AbstractAction { + final ActionModel3 model; + + public CustomEnableAction(ActionModel3 model) { + created++; + instance = this; + setEnabled(false); + this.model = model; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + instance = this; + } + } + + /** + * Checks that custom enable action is enabled on time, instantiated + * only when the guard becomes true. + */ + public void testCustomEnableAction() { + Action a = Actions.forID("Foo", "test.CustomEnableAction"); + assertNotNull(a); + assertFalse("No data in lookup", a.isEnabled()); + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + // still not enabled + assertFalse("Property not set", a.isEnabled()); + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + assertFalse("Action property not set", a.isEnabled()); + Action inst = instance; + inst.setEnabled(true); + assertTrue("Delegate must update enable", a.isEnabled()); + } + + /** + * Checks that an action model is freed, after the actionPerformed is called, + * and then the focus shifts so the model is not in Lookup. + */ + public void testCustomActionModelFreed() { + Action a = Actions.forID("Foo", "test.CustomEnableAction"); + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + + a.actionPerformed(new ActionEvent(this, 0, "cmd")); + + reinitActionLookup(); + + Reference r = new WeakReference(mod); + instance = null; + mod = null; + assertGC("Action model must be GCed", r); + } + + /** + * Check that action with action check enables only after the actual instance enables. + */ + public void testCustomEnableActionInstantiation() { + assertEquals("Not pre-created", 0, created); + Action a = Actions.forID("Foo", "test.CustomEnableAction"); + assertNotNull(a); + assertEquals("Not direcly created from layer", 0, created); + a.isEnabled(); + assertEquals("Not created unless data present", 0, created); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + + // still not enabled + assertFalse(a.isEnabled()); + assertEquals("Not created unless guard is set", 0, created); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + // should instantiate the action just because of the property change on guard, + // now the action decides the final state. + assertEquals("Must be created to evaluate enabled state", 1, created); + + Action inst = instance; + assertNotNull(inst); + + inst.setEnabled(true); + assertSame("Same instance for repeated enable", inst, instance); + + a.actionPerformed(new ActionEvent(this, 0, "cmd")); + assertSame("Same instance for invocation and enable eval", inst, instance); + assertNotNull(received); + assertEquals("cmd", received.getActionCommand()); + } + + /** + * Checks that when the context object is changed, the old custom + * action instance is trashed and a new one is created. + */ + public void testCustomEnableActionChangesWithContext() { + Action a = Actions.forID("Foo", "test.CustomEnableAction"); + ActionModel3 mod = new ActionModel3(); + mod.boolProp = true; + + lookupContent.add(mod); + CustomEnableAction inst = (CustomEnableAction)instance; + assertNotNull(inst); + assertSame(mod, inst.model); + assertFalse(a.isEnabled()); + + inst.setEnabled(true); + assertTrue(a.isEnabled()); + + ActionModel3 mod2 = new ActionModel3(); + instance = null; + lookupContent.remove(mod); + lookupContent.add(mod2); + assertNull(instance); + + mod2.boolProp = true; + mod2.supp.firePropertyChange("boolProp", null, null); + + assertNotNull(instance); + Action save2 = instance; + + assertFalse(a.isEnabled()); + ((AbstractAction)instance).setEnabled(true); + a.actionPerformed(new ActionEvent(this, 0, "x")); + + assertSame(instance, save2); + assertNotSame(instance, inst); + } + + @ActionID(id = "test.ToggleAction", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState(property = "boolProp")) + public static class ToggleAction implements ActionListener { + + public ToggleAction(ActionModel3 model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that toggle action carries the selected key + */ + public void testToggleAction() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleAction"); + assertNotNull(a.getValue(Action.SELECTED_KEY)); + assertTrue(Boolean.TRUE.equals(a.getValue(Actions.ACTION_VALUE_TOGGLE))); + } + + /** + * Checks that the toggle action changes state according to the model + * property. + * @throws Exception + */ + public void testToggleActionEabledStatePropChange1() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleAction"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertFalse("Action must be disabled without data", a.isEnabled()); + assertNull("Must not eagerly instantiate", instance2); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + + assertNull("Must not be created on data presence", instance2); + assertTrue("Must be enabled when data is ready", a.isEnabled()); + assertFalse("Must not be checked unless guard is true", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.boolProp = true; + mod.supp.firePropertyChange("bool2Prop", null, null); + assertFalse("Unrelated property change should be ignored", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.supp.firePropertyChange("boolProp", null, null); + assertTrue("Must become checked after prop change", (Boolean)a.getValue(Action.SELECTED_KEY)); + + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + assertNotNull(received); + assertEquals("cmd2", received.getActionCommand()); + } + + /** + * Checks that an action model is freed, after the actionPerformed is called, + * and then the focus shifts so the model is not in Lookup. + */ + public void testToggleActionModelFreed() { + ActionModel3 mod = new ActionModel3(); + mod.boolProp = true; + lookupContent.add(mod); + + Action a = Actions.forID("Foo", "test.ToggleAction"); + + assertTrue("Must become checked after prop change", (Boolean)a.getValue(Action.SELECTED_KEY)); + a.actionPerformed(new ActionEvent(this, 0, "cmd")); + + reinitActionLookup(); + + Reference r = new WeakReference(mod); + mod = null; + assertGC("Action model must be GCed", r); + } + + + @ActionID(id = "test.ToggleAction3", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState(property = "boolProp")) + public static class ToggleAction3 implements ActionListener { + public ToggleAction3(ActionModel4 model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that the action framework reacts uses {@code addChangeListener} + * @throws Exception + */ + public void testToggleActionEabledStateChange() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleAction3"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertFalse("Action must be disabled without data", a.isEnabled()); + assertNull("Must not eagerly instantiate", instance2); + + ActionModel4 mod = new ActionModel4(); + lookupContent.add(mod); + + assertNull("Must not be created on data presence", instance2); + assertTrue("Must be enabled when data is ready", a.isEnabled()); + assertFalse("Must not be checked unless guard is true", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.boolProp = true; + mod.fire(); + assertTrue("Must become checked after prop change", (Boolean)a.getValue(Action.SELECTED_KEY)); + + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + assertNotNull(received); + assertEquals("cmd2", received.getActionCommand()); + } + + @ActionID(id = "test.ToggleCustomCallback", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState(property = "boolProp", listenOn = CustomListener.class)) + public static class ToggleCustomCallback implements ActionListener { + public ToggleCustomCallback(ActionModel5 model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that the action framework reacts uses custom listener + * @throws Exception + */ + public void testToggleActionEabledCustomIface() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleCustomCallback"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertFalse("Action must be disabled without data", a.isEnabled()); + assertNull("Must not eagerly instantiate", instance2); + + ActionModel5 mod = new ActionModel5(); + lookupContent.add(mod); + + assertNull("Must not be created on data presence", instance2); + assertTrue("Must be enabled when data is ready", a.isEnabled()); + assertFalse("Must not be checked unless guard is true", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.boolProp = true; + mod.fire(); + assertTrue("Must become checked after prop change", (Boolean)a.getValue(Action.SELECTED_KEY)); + + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + assertNotNull(received); + assertEquals("cmd2", received.getActionCommand()); + } + + + @ActionID(id = "test.ToggleAction2", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState(property = "boolProp")) + public static class ToggleAction2 implements ActionListener { + + public ToggleAction2(ActionModel2 model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } + + /** + * Checks that the toggle action changes state according to the model + * property. Checks usage of {@code addPropertyChange(prop, listener)}. + * @throws Exception + */ + public void testToggleActionEabledStatePropChange2() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleAction2"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertFalse("Action must be disabled without data", a.isEnabled()); + assertNull("Must not eagerly instantiate", instance2); + + ActionModel2 mod = new ActionModel2(); + lookupContent.add(mod); + + assertNull("Must not be created on data presence", instance2); + assertTrue("Must be enabled when data is ready", a.isEnabled()); + assertFalse("Must not be checked unless guard is true", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.boolProp = true; + mod.supp.firePropertyChange("bool2Prop", null, null); + assertFalse("Unrelated property change should be ignored", (Boolean)a.getValue(Action.SELECTED_KEY)); + + mod.supp.firePropertyChange("boolProp", null, null); + assertTrue("Must become checked after prop change", (Boolean)a.getValue(Action.SELECTED_KEY)); + + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + assertNotNull(received); + assertEquals("cmd2", received.getActionCommand()); + } + + /** + * Checks that toggle action is not instantiated prematurely + */ + public void testToggleActionInstantiate() throws Exception { + Action a = Actions.forID("Foo", "test.ToggleAction"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertNull("Must not eagerly instantiate", instance2); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + assertNull("Must not instantiate just on data presence", instance2); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + assertNull("Must not instantiate just when guard goes true", instance2); + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + // instantiated + assertNotNull(instance2); + } + + @ActionID(id = "test.CustomToggleAction", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState(property = "boolProp", useActionInstance = true)) + public static class CustomToggleAction extends AbstractAction { + ActionModel3 aModel; + + public CustomToggleAction(ActionModel3 model) { + created++; + instance = this; + setEnabled(false); + this.aModel = model; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + + @Override + public boolean isEnabled() { + return super.isEnabled(); //To change body of generated methods, choose Tools | Templates. + } + + } + + /** + * Checks that the toggle action will be queried for its checked state + */ + public void testToggleActionCustomState() throws Exception { + Action a = Actions.forID("Foo", "test.CustomToggleAction"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertNull("Must not eagerly instantiate", instance); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + assertNull("Must not instantiate just on data presence", instance); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + Action save = instance; + assertNotNull("Must instantiate for check evaluation", instance); + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + + // still same instance + assertNotNull(instance); + assertSame(save, instance); + } + + /** + * Checks that the action instance changes as the context object changes, + * similar to the test for enabling action. + */ + public void testCustomActionChangesWithContext() throws Exception { + Action a = Actions.forID("Foo", "test.CustomToggleAction"); + assertFalse("Action must be unchecked", (Boolean)a.getValue(Action.SELECTED_KEY)); + assertNull("Must not eagerly instantiate", instance); + + ActionModel3 mod = new ActionModel3(); + ActionModel3 mod2 = new ActionModel3(); + + lookupContent.add(mod); + assertNull("Must not instantiate just on data presence", instance); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + + assertNotNull("Must instantiate for check evaluation", instance); + assertTrue(a.isEnabled()); + + CustomToggleAction saveTA = (CustomToggleAction)instance; + assertSame(mod, saveTA.aModel); + instance = null; + lookupContent.remove(mod); + lookupContent.add(mod2); + // not created, the guard condition is not true yet + assertNull(instance); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + // still not created, fired on old model + assertNull(instance); + + mod2.boolProp = true; + mod2.supp.firePropertyChange("boolProp", null, null); + // now guard becomes true, action must be created + assertNotNull(instance); + assertNotSame(saveTA, instance); + + CustomToggleAction nowTA = (CustomToggleAction)instance; + assertSame(mod2, nowTA.aModel); + } + + /** + * Checks how Action.isEnabled() tracks changes in the context and + * property changes of the context objects + */ + public void testContextActionEnableChanges() throws Exception { + InstanceContent localContent1 = new InstanceContent(); + AbstractLookup localLookup1 = new AbstractLookup(localContent1); + InstanceContent localContent2 = new InstanceContent(); + AbstractLookup localLookup2 = new AbstractLookup(localContent2); + + ActionModel3 mdlGlobal = new ActionModel3(); + ActionModel3 mdlGlobal2 = new ActionModel3(); + lookupContent.add(mdlGlobal); + + Action a = Actions.forID("Foo", "test.InstAction"); + assertFalse("Must be disabled before guard is set", a.isEnabled()); + + mdlGlobal.boolProp = true; + mdlGlobal.supp.firePropertyChange("boolProp", null, null); + assertTrue("Must turn enabled after guard change", a.isEnabled()); + + // adopt into local context + localContent1.add(mdlGlobal); + Action localA = ((ContextAwareAction)a).createContextAwareInstance(localLookup1); + assertTrue("Context action enable must initialize", localA.isEnabled()); + + // turn to false + mdlGlobal.boolProp = false; + mdlGlobal.supp.firePropertyChange("boolProp", null, null); + assertFalse("Global action must follow guard", a.isEnabled()); + assertFalse("Context action must follow guard", localA.isEnabled()); + + mdlGlobal.boolProp = true; + mdlGlobal.supp.firePropertyChange("boolProp", null, null); + assertTrue(a.isEnabled()); + assertTrue(localA.isEnabled()); + + // remove/replace the model in global Lookup + lookupContent.remove(mdlGlobal); + assertFalse("Global action must follow its Lookup", a.isEnabled()); + assertTrue("Context action must listen on its Lookup", localA.isEnabled()); + + lookupContent.add(mdlGlobal2); + assertFalse(a.isEnabled()); + assertTrue(localA.isEnabled()); + + mdlGlobal2.boolProp = true; + mdlGlobal2.supp.firePropertyChange("boolProp", null, null); + + assertTrue("Global action must enbale on new global guard", a.isEnabled()); + assertTrue(localA.isEnabled()); + + mdlGlobal.boolProp = false; + mdlGlobal.supp.firePropertyChange("boolProp", null, null); + assertFalse("Context action must follow remembered guard", localA.isEnabled()); + + ActionModel3 mdl3 = new ActionModel3(); + localContent2.add(mdl3); + + Action localB = ((ContextAwareAction)a).createContextAwareInstance(localLookup2); + assertFalse(localB.isEnabled()); + + mdl3.boolProp = true; + mdl3.supp.firePropertyChange("boolProp", null, null); + assertTrue(localB.isEnabled()); + assertFalse(localA.isEnabled()); + } + + /** + * Checks that the state object that goes out of lookup is not strong-held + * by action system + */ + public void testStateObjectWillGC() throws Exception { + Action a = Actions.forID("Foo", "test.CustomToggleAction"); + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + assertNull("Must not instantiate just on data presence", instance); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + assertNotNull(instance); + assertTrue(a.isEnabled()); + + lookupContent.remove(mod); + + mod = null; + instance = null; + + Reference r = new WeakReference<>(mod); + assertGC("Obsolete model object must GC", r); + } + + /** + * Checks that an obsolete custom action instance is released when + * its context object goes away form Lookup and the action instance + * can be GCed. + */ + public void testOldContextActionWillGC() throws Exception { + Action a = Actions.forID("Foo", "test.CustomToggleAction"); + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + assertNull("Must not instantiate just on data presence", instance); + + mod.boolProp = true; + mod.supp.firePropertyChange("boolProp", null, null); + assertNotNull(instance); + assertTrue(a.isEnabled()); + + lookupContent.remove(mod); + Reference r = new WeakReference<>(instance); + instance = null; + mod = null; + + assertGC("Obsolete model object must GC", r); + } + + @ActionID(id = "test.CustomEnableAction2", category="Foo") + @ActionRegistration(displayName = "TestAction", enabledOn = @ActionState(useActionInstance = true)) + public static class CustomEnableAction2 extends AbstractAction { + final ActionModel3 model; + + public CustomEnableAction2(ActionModel3 model) { + this.model = model; + setEnabled(false); + instance = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + instance = this; + } + } + + /** + * Checks that custom action isEnable is called when no property is specified + */ + public void testCustomEnableActionNoPropety() { + Action a = Actions.forID("Foo", "test.CustomEnableAction2"); + assertFalse(a.isEnabled()); + + ActionModel3 mod = new ActionModel3(); + lookupContent.add(mod); + assertNotNull(instance); + + CustomEnableAction2 save = (CustomEnableAction2)instance; + instance.setEnabled(true); + assertTrue(a.isEnabled()); + + a.actionPerformed(new ActionEvent(this, 0, "cmd2")); + assertSame(save, instance); + } + + @ActionID(id = "test.ListAction", category="Foo") + @ActionRegistration(displayName = "TestAction", checkedOn = @ActionState( + property = "minSelectionIndex", listenOn = ListSelectionListener.class, listenOnMethod="valueChanged" + )) + public static class ListAction implements ActionListener { + public ListAction(ListSelectionModel model) { + instance2 = this; + } + + @Override + public void actionPerformed(ActionEvent e) { + received = e; + } + } +} diff --git a/openide.util.ui/apichanges.xml b/openide.util.ui/apichanges.xml index 35f9c11..c81a40a 100644 --- a/openide.util.ui/apichanges.xml +++ b/openide.util.ui/apichanges.xml @@ -27,6 +27,22 @@ Actions API + + + BooleanStateAction deprecated in favour of Actions API and @ActionState annotation. + + + + + +

    + The BooleanStateAction base class was deprecated, as + there's a programatic API in Actions + and a declarative @ActionState annotation which fully supersede the deprecated class. +

    +
    + +
    API NetworkSettings.getAuthenticationPassword added diff --git a/openide.util.ui/manifest.mf b/openide.util.ui/manifest.mf index 7858e67..4a6ac34 100644 --- a/openide.util.ui/manifest.mf +++ b/openide.util.ui/manifest.mf @@ -1,5 +1,5 @@ Manifest-Version: 1.0 OpenIDE-Module: org.openide.util.ui OpenIDE-Module-Localizing-Bundle: org/openide/util/Bundle.properties -OpenIDE-Module-Specification-Version: 9.10 +OpenIDE-Module-Specification-Version: 9.11 diff --git a/openide.util.ui/src/org/openide/util/actions/BooleanStateAction.java b/openide.util.ui/src/org/openide/util/actions/BooleanStateAction.java index d597af8..4abc7f1 100644 --- a/openide.util.ui/src/org/openide/util/actions/BooleanStateAction.java +++ b/openide.util.ui/src/org/openide/util/actions/BooleanStateAction.java @@ -27,10 +27,13 @@ package org.openide.util.actions; * This action is not the most effective way to implement checkbox in * a menu. Consider using more modern alternative: * -* Actions.checkbox. +* Actions.checkbox, or declarative ActionState annotation. * * @author Ian Formanek, Petr Hamernik +* @deprecated Use new support for stateful actions in Actions or ActionState annotation +* */ +@Deprecated public abstract class BooleanStateAction extends SystemAction implements Presenter.Menu, Presenter.Popup, Presenter.Toolbar { /** serialVersionUID */ --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscribe@netbeans.apache.org For additional commands, e-mail: commits-help@netbeans.apache.org For further information about the NetBeans mailing lists, visit: https://cwiki.apache.org/confluence/display/NETBEANS/Mailing+lists