/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tools.idea.structure; import com.android.sdklib.AndroidTargetHash; import com.android.sdklib.AndroidVersion; import com.android.sdklib.BuildToolInfo; import com.android.sdklib.IAndroidTarget; import com.android.sdklib.repository.descriptors.PkgType; import com.android.sdklib.repository.local.LocalBuildToolPkgInfo; import com.android.sdklib.repository.local.LocalPkgInfo; import com.android.sdklib.repository.local.LocalSdk; import com.android.tools.idea.gradle.parser.BuildFileKey; import com.android.tools.idea.gradle.parser.BuildFileKeyType; import com.android.tools.idea.gradle.parser.GradleBuildFile; import com.google.common.base.Joiner; import com.google.common.base.Objects; import com.google.common.base.Splitter; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.ImmutableBiMap; import com.google.common.collect.ImmutableMap; import com.intellij.openapi.fileChooser.FileChooserDescriptor; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.ComboBox; import com.intellij.openapi.ui.TextBrowseFolderListener; import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.ui.JBColor; import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBTextField; import com.intellij.uiDesigner.core.GridConstraints; import com.intellij.uiDesigner.core.GridLayoutManager; import org.jetbrains.android.sdk.AndroidSdkData; import org.jetbrains.android.sdk.AndroidSdkUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.*; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import java.awt.*; import java.awt.event.ActionListener; import java.awt.event.ItemEvent; import java.awt.event.ItemListener; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; public class KeyValuePane extends JPanel implements DocumentListener, ItemListener { /** * Listener class that gets called any time the value for the given key is modified in the UI. This should be used to mark that * value is "dirty" and ensure it gets written out to the build file. */ public interface ModificationListener { void modified(@NotNull BuildFileKey key); } private final BiMap<BuildFileKey, JComponent> myProperties = HashBiMap.create(); private boolean myIsUpdating; private Map<BuildFileKey, Object> myCurrentBuildFileObject; private Map<BuildFileKey, Object> myCurrentModelObject; private final Project myProject; private final ModificationListener myListener; /** * This structure lets us define known values to populate combo boxes for some keys. The user can choose one of those known values from * the combo box, or enter a custom value. This structure is a map, where the key to the map is the BuildFileKey and the value is a * sub-map. This sub-map lets us show different strings in the combo box in the UI than what we actually read and write to the underlying * build file. For example, you can have a targetSdkVersion of 20 in the build file, but it will show that in the UI as * "API 20: Android 4.4 (KitKat Wear)". This sub-map is bi-directional, and has keys of the values that appear in the build file, and * values as what appears in the UI. For simple cases where the UI shows the same thing that appears in the build file, this can be * an identity mapping. */ private final Map<BuildFileKey, BiMap<String, String>> myKeysWithKnownValues; public KeyValuePane(@NotNull Project project, @NotNull ModificationListener listener) { myProject = project; myListener = listener; LocalSdk sdk = null; AndroidSdkData androidSdkData = AndroidSdkUtils.tryToChooseAndroidSdk(); if (androidSdkData != null) { sdk = androidSdkData.getLocalSdk(); } // Use immutable maps with builders for our built-in value maps because ImmutableBiMap ensures that iteration order is the same as // insertion order. ImmutableBiMap.Builder<String, String> buildToolsMapBuilder = ImmutableBiMap.builder(); ImmutableBiMap.Builder<String, String> apisMapBuilder = ImmutableBiMap.builder(); ImmutableBiMap.Builder<String, String> compiledApisMapBuilder = ImmutableBiMap.builder(); if (sdk != null) { LocalPkgInfo[] buildToolsPackages = sdk.getPkgsInfos(PkgType.PKG_BUILD_TOOLS); for (LocalPkgInfo buildToolsPackage : buildToolsPackages) { if (!(buildToolsPackage instanceof LocalBuildToolPkgInfo)) { continue; } BuildToolInfo buildToolInfo = ((LocalBuildToolPkgInfo)buildToolsPackage).getBuildToolInfo(); if (buildToolInfo == null) { continue; } String buildToolVersion = buildToolInfo.getRevision().toString(); buildToolsMapBuilder.put(buildToolVersion, buildToolVersion); } for (IAndroidTarget target : sdk.getTargets()) { if (target.isPlatform()) { AndroidVersion version = target.getVersion(); String codename = version.getCodename(); String apiString, platformString; if (codename != null) { apiString = codename; platformString = AndroidTargetHash.getPlatformHashString(version); } else { platformString = apiString = Integer.toString(version.getApiLevel()); } String label = AndroidSdkUtils.getTargetLabel(target); apisMapBuilder.put(apiString, label); compiledApisMapBuilder.put(platformString, label); } } } BiMap<String, String> installedBuildTools = buildToolsMapBuilder.build(); BiMap<String, String> installedApis = apisMapBuilder.build(); BiMap<String, String> installedCompileApis = compiledApisMapBuilder.build(); BiMap<String, String> javaCompatibility = ImmutableBiMap.of("JavaVersion.VERSION_1_6", "1.6", "JavaVersion.VERSION_1_7", "1.7"); myKeysWithKnownValues = ImmutableMap.<BuildFileKey, BiMap<String, String>>builder() .put(BuildFileKey.MIN_SDK_VERSION, installedApis) .put(BuildFileKey.TARGET_SDK_VERSION, installedApis) .put(BuildFileKey.COMPILE_SDK_VERSION, installedCompileApis) .put(BuildFileKey.BUILD_TOOLS_VERSION, installedBuildTools) .put(BuildFileKey.SOURCE_COMPATIBILITY, javaCompatibility) .put(BuildFileKey.TARGET_COMPATIBILITY, javaCompatibility) .build(); } /** * Sets the current object as seen by parsing the build file directly. This controls what the user explicitly sets through the build file. * Any keys that are set to null are unset in the build file and will take on default values when the build file is executed. */ public void setCurrentBuildFileObject(@Nullable Map<BuildFileKey, Object> currentBuildFileObject) { myCurrentBuildFileObject = currentBuildFileObject; } /** * Sets the current object as seen by querying the Gradle model after the build file is evaluated. This shows the user what the build file * will actually do, showing the default values of keys (as supplied by the plugin) that are otherwise not explicitly set in the file. */ public void setCurrentModelObject(@Nullable Map<BuildFileKey, Object> currentModelObject) { myCurrentModelObject = currentModelObject; } public void init(GradleBuildFile gradleBuildFile, Collection<BuildFileKey>properties) { GridLayoutManager layout = new GridLayoutManager(properties.size() + 1, 2); setLayout(layout); GridConstraints constraints = new GridConstraints(); constraints.setAnchor(GridConstraints.ANCHOR_WEST); constraints.setVSizePolicy(GridConstraints.SIZEPOLICY_FIXED); for (BuildFileKey property : properties) { constraints.setColumn(0); constraints.setFill(GridConstraints.FILL_NONE); constraints.setHSizePolicy(GridConstraints.SIZEPOLICY_FIXED); add(new JBLabel(property.getDisplayName()), constraints); constraints.setColumn(1); constraints.setFill(GridConstraints.FILL_HORIZONTAL); constraints.setHSizePolicy(GridConstraints.SIZEPOLICY_WANT_GROW); JComponent component; switch(property.getType()) { case BOOLEAN: { constraints.setFill(GridConstraints.FILL_NONE); ComboBox comboBox = getComboBox(false); comboBox.addItem(""); comboBox.addItem("true"); comboBox.addItem("false"); comboBox.setPrototypeDisplayValue("(false) "); component = comboBox; break; } case FILE: case FILE_AS_STRING: { JBTextField textField = new JBTextField(); TextFieldWithBrowseButton fileField = new TextFieldWithBrowseButton(textField); FileChooserDescriptor d = new FileChooserDescriptor(true, false, false, true, false, false); d.setShowFileSystemRoots(true); fileField.addBrowseFolderListener(new TextBrowseFolderListener(d)); fileField.getTextField().getDocument().addDocumentListener(this); component = fileField; break; } case REFERENCE: { constraints.setFill(GridConstraints.FILL_NONE); ComboBox comboBox = getComboBox(true); if (hasKnownValues(property)) { for (String s : myKeysWithKnownValues.get(property).values()) { comboBox.addItem(s); } } // If there are no hardcoded values, the combo box's values will get populated later when the panel for the container reference // type wakes up and notifies us of its current values. component = comboBox; break; } case CLOSURE: case STRING: case INTEGER: default: { if (hasKnownValues(property)) { constraints.setFill(GridConstraints.FILL_NONE); ComboBox comboBox = getComboBox(true); for (String s : myKeysWithKnownValues.get(property).values()) { comboBox.addItem(s); } component = comboBox; } else { JBTextField textField = new JBTextField(); textField.getDocument().addDocumentListener(this); component = textField; } break; } } add(component, constraints); myProperties.put(property, component); constraints.setRow(constraints.getRow() + 1); } constraints.setColumn(0); constraints.setVSizePolicy(GridConstraints.FILL_VERTICAL); constraints.setHSizePolicy(GridConstraints.SIZEPOLICY_FIXED); add(new JBLabel(""), constraints); updateUiFromCurrentObject(); } public void updateReferenceValues(@NotNull BuildFileKey containerProperty, @NotNull Iterable<String> values) { BuildFileKey itemType = containerProperty.getItemType(); if (itemType == null) { return; } ComboBox comboBox = (ComboBox)myProperties.get(itemType); if (comboBox == null) { return; } myIsUpdating = true; try { String currentValue = comboBox.getEditor().getItem().toString(); comboBox.removeAllItems(); for (String value : values) { comboBox.addItem(value); } comboBox.setSelectedItem(currentValue); } finally { myIsUpdating = false; } } private ComboBox getComboBox(boolean editable) { ComboBox comboBox = new ComboBox(); comboBox.addItemListener(this); comboBox.setEditor(new ComboBoxEditor() { private final JBTextField myTextField = new JBTextField(); @Override public Component getEditorComponent() { return myTextField; } @Override public void setItem(Object o) { myTextField.setText(o != null ? o.toString() : ""); } @Override public Object getItem() { return myTextField.getText(); } @Override public void selectAll() { myTextField.selectAll(); } @Override public void addActionListener(ActionListener actionListener) { } @Override public void removeActionListener(ActionListener actionListener) { } }); comboBox.setEditable(true); JBTextField editorComponent = (JBTextField)comboBox.getEditor().getEditorComponent(); editorComponent.setEditable(editable); editorComponent.getDocument().addDocumentListener(this); return comboBox; } /** * Reads the state of the UI form objects and writes them into the currently selected object in the list, setting the dirty bit as * appropriate. */ private void updateCurrentObjectFromUi() { if (myIsUpdating || myCurrentBuildFileObject == null) { return; } for (Map.Entry<BuildFileKey, JComponent> entry : myProperties.entrySet()) { BuildFileKey key = entry.getKey(); JComponent component = entry.getValue(); Object currentValue = myCurrentBuildFileObject.get(key); Object newValue; BuildFileKeyType type = key.getType(); switch(type) { case BOOLEAN: { ComboBox comboBox = (ComboBox)component; JBTextField editorComponent = (JBTextField)comboBox.getEditor().getEditorComponent(); int index = comboBox.getSelectedIndex(); if (index == 2) { newValue = Boolean.FALSE; editorComponent.setForeground(JBColor.BLACK); } else if (index == 1) { newValue = Boolean.TRUE; editorComponent.setForeground(JBColor.BLACK); } else { newValue = null; editorComponent.setForeground(JBColor.GRAY); } break; } case FILE: case FILE_AS_STRING: { newValue = ((TextFieldWithBrowseButton)component).getText(); if ("".equals(newValue)) { newValue = null; } if (newValue != null) { newValue = new File(newValue.toString()); } break; } case INTEGER: { try { if (hasKnownValues(key)) { String newStringValue = ((ComboBox)component).getEditor().getItem().toString(); newStringValue = getMappedValue(myKeysWithKnownValues.get(key).inverse(), newStringValue); newValue = Integer.valueOf(newStringValue); } else { newValue = Integer.valueOf(((JBTextField)component).getText()); } } catch (Exception e) { newValue = null; } break; } case REFERENCE: { newValue = ((ComboBox)component).getEditor().getItem(); String newStringValue = (String)newValue; if (hasKnownValues(key)) { newStringValue = getMappedValue(myKeysWithKnownValues.get(key).inverse(), newStringValue); } if (newStringValue != null && newStringValue.isEmpty()) { newStringValue = null; } String prefix = getReferencePrefix(key); if (newStringValue != null && !newStringValue.startsWith(prefix)) { newStringValue = prefix + newStringValue; } newValue = newStringValue; break; } case CLOSURE: case STRING: default: { if (hasKnownValues(key)) { String newStringValue = ((ComboBox)component).getEditor().getItem().toString(); newStringValue = getMappedValue(myKeysWithKnownValues.get(key).inverse(), newStringValue); if (newStringValue.isEmpty()) { newStringValue = null; } newValue = newStringValue; } else { newValue = ((JBTextField)component).getText(); if ("".equals(newValue)) { newValue = null; } } if (type == BuildFileKeyType.CLOSURE && newValue != null) { List newListValue = new ArrayList(); for (String s : Splitter.on(',').omitEmptyStrings().trimResults().split((String)newValue)) { newListValue.add(key.getValueFactory().parse(s, myProject)); } newValue = newListValue; } break; } } if (!Objects.equal(currentValue, newValue)) { if (newValue == null) { myCurrentBuildFileObject.remove(key); } else { myCurrentBuildFileObject.put(key, newValue); } if (GradleBuildFile.shouldWriteValue(currentValue, newValue)) { myListener.modified(key); } } } } /** * Updates the form UI objects to reflect the currently selected object. Clears the objects and disables them if there is no selected * object. */ public void updateUiFromCurrentObject() { myIsUpdating = true; for (Map.Entry<BuildFileKey, JComponent> entry : myProperties.entrySet()) { BuildFileKey key = entry.getKey(); JComponent component = entry.getValue(); Object value = myCurrentBuildFileObject != null ? myCurrentBuildFileObject.get(key) : null; final Object modelValue = myCurrentModelObject != null ? myCurrentModelObject.get(key) : null; switch(key.getType()) { case BOOLEAN: { ComboBox comboBox = (ComboBox)component; String text = formatDefaultValue(modelValue); comboBox.removeItemAt(0); comboBox.insertItemAt(text, 0); JBTextField editorComponent = (JBTextField)comboBox.getEditor().getEditorComponent(); if (Boolean.FALSE.equals(value)) { comboBox.setSelectedIndex(2); editorComponent.setForeground(JBColor.BLACK); } else if (Boolean.TRUE.equals(value)) { comboBox.setSelectedIndex(1); editorComponent.setForeground(JBColor.BLACK); } else { comboBox.setSelectedIndex(0); editorComponent.setForeground(JBColor.GRAY); } break; } case FILE: case FILE_AS_STRING: { TextFieldWithBrowseButton fieldWithButton = (TextFieldWithBrowseButton)component; fieldWithButton.setText(value != null ? value.toString() : ""); JBTextField textField = (JBTextField)fieldWithButton.getTextField(); textField.getEmptyText().setText(formatDefaultValue(modelValue)); break; } case REFERENCE: { String stringValue = (String)value; if (hasKnownValues(key) && stringValue != null) { stringValue = getMappedValue(myKeysWithKnownValues.get(key), stringValue); } String prefix = getReferencePrefix(key); if (stringValue == null) { stringValue = ""; } else if (stringValue.startsWith(prefix)) { stringValue = stringValue.substring(prefix.length()); } ComboBox comboBox = (ComboBox)component; JBTextField textField = (JBTextField)comboBox.getEditor().getEditorComponent(); textField.getEmptyText().setText(formatDefaultValue(modelValue)); comboBox.setSelectedItem(stringValue); break; } case CLOSURE: if (value instanceof List) { value = Joiner.on(", ").join((List)value); } // Fall through to INTEGER/STRING/default case case INTEGER: case STRING: default: { if (hasKnownValues(key)) { if (value != null) { value = getMappedValue(myKeysWithKnownValues.get(key), value.toString()); } ComboBox comboBox = (ComboBox)component; comboBox.setSelectedItem(value != null ? value.toString() : ""); JBTextField textField = (JBTextField)comboBox.getEditor().getEditorComponent(); textField.getEmptyText().setText(formatDefaultValue(modelValue)); } else { JBTextField textField = (JBTextField)component; textField.setText(value != null ? value.toString() : ""); textField.getEmptyText().setText(formatDefaultValue(modelValue)); } break; } } component.setEnabled(myCurrentBuildFileObject != null); } myIsUpdating = false; } @NotNull private static String formatDefaultValue(@Nullable Object modelValue) { if (modelValue == null) { return ""; } String s = modelValue.toString(); return !s.isEmpty() ? "(" + s + ")" : ""; } @NotNull private static String getMappedValue(@NotNull BiMap<String, String> map, @NotNull String value) { if (map.containsKey(value)) { value = map.get(value); } return value; } private boolean hasKnownValues(BuildFileKey key) { return myKeysWithKnownValues.containsKey(key); } @Override public void insertUpdate(@NotNull DocumentEvent documentEvent) { updateCurrentObjectFromUi(); } @Override public void removeUpdate(@NotNull DocumentEvent documentEvent) { updateCurrentObjectFromUi(); } @Override public void changedUpdate(@NotNull DocumentEvent documentEvent) { updateCurrentObjectFromUi(); } @Override public void itemStateChanged(ItemEvent event) { if (event.getStateChange() == ItemEvent.SELECTED) { updateCurrentObjectFromUi(); } } @NotNull private static String getReferencePrefix(@NotNull BuildFileKey key) { BuildFileKey containerType = key.getContainerType(); if (containerType != null) { String path = containerType.getPath(); String lastLeaf = path.substring(path.lastIndexOf('/') + 1); return lastLeaf + "."; } else { return ""; } } }