blob: b71788c2b57370cc677417fa8199904f59d36305 [file] [log] [blame]
/*
* Copyright (C) 2015 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.editors.theme.preview;
import com.android.ide.common.rendering.api.MergeCookie;
import com.android.ide.common.rendering.api.ViewInfo;
import com.android.tools.idea.configurations.Configuration;
import com.android.tools.idea.configurations.ConfigurationListener;
import com.android.tools.idea.configurations.RenderContext;
import com.android.tools.idea.editors.theme.ThemeEditorContext;
import com.android.tools.idea.editors.theme.ThemeEditorUtils;
import com.android.tools.idea.editors.theme.preview.ThemePreviewBuilder.ComponentDefinition;
import com.android.tools.idea.rendering.RenderResult;
import com.android.tools.idea.rendering.RenderedViewHierarchy;
import com.android.tools.idea.rendering.multi.RenderPreviewManager;
import com.android.tools.swing.layoutlib.AndroidPreviewPanel;
import com.android.tools.swing.ui.NavigationComponent;
import com.google.common.base.Objects;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.DumbService;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.JavaPsiFacade;
import com.intellij.psi.PsiClass;
import com.intellij.psi.search.GlobalSearchScope;
import com.intellij.psi.search.ProjectScope;
import com.intellij.psi.search.searches.ClassInheritorsSearch;
import com.intellij.psi.xml.XmlFile;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.util.Processor;
import com.intellij.util.Query;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.UIUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ScrollPaneConstants;
import javax.xml.parsers.ParserConfigurationException;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
/**
* Component that renders a theme preview. The preview size is independent of the selected device DPI so controls size will remain constant
* for all devices.
* Implements RenderContext to support Configuration toolbar in Theme Editor.
*/
public class AndroidThemePreviewPanel extends Box implements RenderContext {
private static final Logger LOG = Logger.getInstance(AndroidThemePreviewPanel.class);
// The scaling factor is based on how we want the preview to look for different devices. 160 means that a device with 160 DPI would look
// exactly as GraphicsLayoutRenderer would render it. A device with 300 DPI would usually look smaller but because we apply this scaling
// factor, it would be scaled 2x to look exactly as the 100 DPI version would look.
private static final double DEFAULT_SCALING_FACTOR = 160.0;
private static final Map<String, ComponentDefinition> SUPPORT_LIBRARY_COMPONENTS =
ImmutableMap.of("android.support.design.widget.FloatingActionButton",
new ComponentDefinition("Fab", ThemePreviewBuilder.ComponentGroup.FAB_BUTTON,
"android.support.design.widget.FloatingActionButton")
.set("src", "@drawable/abc_ic_ab_back_mtrl_am_alpha").set("clickable", "true"),
"android.support.v7.widget.Toolbar",
new ToolbarComponentDefinition(true/*isAppCompat*/));
private static final Map<String, String> SUPPORT_LIBRARY_REPLACEMENTS =
ImmutableMap.of("android.support.v7.widget.Toolbar", "Toolbar");
/** Enable the component drill down that allows to see only selected groups of components on click */
private static final boolean ENABLE_COMPONENTS_DRILL_DOWN = false;
/** Current search term to use for filtering. If empty, no search term is being used */
private String mySearchTerm = "";
/** Cache of the custom components found on the project */
private List<ComponentDefinition> myCustomComponents = Collections.emptyList();
/** List of components on the support library (if available) */
private List<ComponentDefinition> mySupportLibraryComponents = Collections.emptyList();
/** List of component names that shouldn't be displayed. This is used in the case where a support component superseeds a framework one. */
private List<String> myDisabledComponents = new ArrayList<String>();
private final ThemeEditorContext myContext;
protected final NavigationComponent<Breadcrumb> myBreadcrumbs;
protected final AndroidPreviewPanel myAndroidPreviewPanel;
protected final JBScrollPane myScrollPane;
protected DumbService myDumbService;
/** Filters components that are disabled or that do not belong to the current selected group */
private final Predicate<ComponentDefinition> myGroupFilter = new Predicate<ComponentDefinition>() {
@Override
public boolean apply(@Nullable ComponentDefinition input) {
if (input == null) {
return false;
}
Breadcrumb breadcrumb = myBreadcrumbs.peek();
return (breadcrumb == null || breadcrumb.myGroup == null || breadcrumb.myGroup.equals(input.group));
}
};
private final Predicate<ComponentDefinition> mySupportReplacementsFilter = new Predicate<ComponentDefinition>() {
@Override
public boolean apply(@Nullable ComponentDefinition input) {
if (input == null) {
return false;
}
return !myDisabledComponents.contains(input.name);
}
};
private double myConstantScalingFactor = DEFAULT_SCALING_FACTOR;
private boolean myIsAppCompatTheme = false;
static class Breadcrumb extends NavigationComponent.Item {
private final ThemePreviewBuilder.ComponentGroup myGroup;
private final String myDisplayText;
private Breadcrumb(@NotNull String name, @Nullable ThemePreviewBuilder.ComponentGroup group) {
myDisplayText = name;
myGroup = group;
}
public Breadcrumb(@NotNull String name) {
this(name, null);
}
public Breadcrumb(@NotNull ThemePreviewBuilder.ComponentGroup group) {
this(group.name, group);
}
@Override
@NotNull
public String getDisplayText() {
return myDisplayText;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Breadcrumb that = (Breadcrumb)o;
return Objects.equal(myGroup, that.myGroup) && Objects.equal(myDisplayText, that.myDisplayText);
}
@Override
public int hashCode() {
return Objects.hashCode(myGroup, myDisplayText);
}
}
public AndroidThemePreviewPanel(@NotNull ThemeEditorContext context, @NotNull Color background) {
super(BoxLayout.PAGE_AXIS);
setOpaque(true);
setMinimumSize(JBUI.size(200, 0));
myContext = context;
myAndroidPreviewPanel = new AndroidPreviewPanel(myContext.getConfiguration());
myContext.addChangeListener(new ThemeEditorContext.ChangeListener() {
@Override
public void onNewConfiguration(ThemeEditorContext context) {
refreshConfiguration();
}
});
myAndroidPreviewPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
myBreadcrumbs = new NavigationComponent<Breadcrumb>();
myBreadcrumbs.setFont(UIUtil.getLabelFont(UIUtil.FontSize.SMALL));
myDumbService = DumbService.getInstance(context.getProject());
myScrollPane = new JBScrollPane(myAndroidPreviewPanel,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
myScrollPane.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
myScrollPane.setBorder(null);
myScrollPane.setViewportBorder(null);
myBreadcrumbs.setRootItem(new Breadcrumb("All components"));
add(Box.createRigidArea(new Dimension(0, 5)));
add(myBreadcrumbs);
add(Box.createRigidArea(new Dimension(0, 10)));
add(myScrollPane);
setBackground(background);
reloadComponents();
myBreadcrumbs.addItemListener(new NavigationComponent.ItemListener<Breadcrumb>() {
@Override
public void itemSelected(@NotNull Breadcrumb item) {
myBreadcrumbs.goTo(item);
rebuild();
}
});
if (ENABLE_COMPONENTS_DRILL_DOWN) {
myAndroidPreviewPanel.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
ViewInfo view = myAndroidPreviewPanel.findViewAtPoint(e.getPoint());
if (view == null) {
return;
}
Object cookie = view.getCookie();
if (cookie instanceof MergeCookie) {
cookie = ((MergeCookie)cookie).getCookie();
}
if (!(cookie instanceof Element)) {
return;
}
NamedNodeMap attributes = ((Element)cookie).getAttributes();
Node group = attributes.getNamedItemNS(ThemePreviewBuilder.BUILDER_URI, ThemePreviewBuilder.BUILDER_ATTR_GROUP);
if (group != null) {
myBreadcrumbs.push(new Breadcrumb(ThemePreviewBuilder.ComponentGroup.valueOf(group.getNodeValue())));
rebuild();
}
}
});
}
myContext.addConfigurationListener(new ConfigurationListener() {
@Override
public boolean changed(int flags) {
refreshConfiguration();
if (ThemeEditorUtils.isAppCompatTheme(myContext.getConfiguration()) != myIsAppCompatTheme) {
rebuild();
}
return true;
}
});
}
/**
* Set a search term to filter components in a preview, will trigger a delayed update.
*/
public void setSearchTerm(@NotNull String searchTerm) {
if (searchTerm.equals(mySearchTerm)) {
return;
}
mySearchTerm = searchTerm;
rebuild();
}
@Override
public void setBackground(Color bg) {
super.setBackground(bg);
myScrollPane.getViewport().setBackground(bg);
myBreadcrumbs.setBackground(bg);
}
/**
* Searches the PSI for both custom components and support library classes. Call this method when you
* want to refresh the components displayed on the preview.
*/
public void reloadComponents() {
myDumbService.runWhenSmart(new Runnable() {
@Override
public void run() {
Project project = myContext.getProject();
if (!project.isOpen()) {
return;
}
PsiClass viewClass = JavaPsiFacade.getInstance(project).findClass("android.view.View", GlobalSearchScope.allScope(project));
if (viewClass == null) {
LOG.error("Unable to find 'android.view.View'");
return;
}
Query<PsiClass> viewClasses = ClassInheritorsSearch.search(viewClass, ProjectScope.getProjectScope(project), true);
final ArrayList<ComponentDefinition> customComponents =
new ArrayList<ComponentDefinition>();
viewClasses.forEach(new Processor<PsiClass>() {
@Override
public boolean process(PsiClass psiClass) {
String description = psiClass.getName(); // We use the "simple" name as description on the preview.
String className = psiClass.getQualifiedName();
if (description == null || className == null) {
// Currently we ignore anonymous views
return false;
}
customComponents.add(new ComponentDefinition(description, ThemePreviewBuilder.ComponentGroup.CUSTOM, className));
return true;
}
});
// Now search for support library components. We use a HashSet to avoid adding duplicate components from source and jar files.
myDisabledComponents.clear();
final HashSet<ComponentDefinition> supportLibraryComponents = new HashSet<ComponentDefinition>();
viewClasses = ClassInheritorsSearch.search(viewClass, ProjectScope.getLibrariesScope(project), true);
viewClasses.forEach(new Processor<PsiClass>() {
@Override
public boolean process(PsiClass psiClass) {
String className = psiClass.getQualifiedName();
ComponentDefinition component = SUPPORT_LIBRARY_COMPONENTS.get(className);
if (component != null) {
supportLibraryComponents.add(component);
String replaces = SUPPORT_LIBRARY_REPLACEMENTS.get(className);
if (replaces != null) {
myDisabledComponents.add(replaces);
}
}
return true;
}
});
myCustomComponents = Collections.unmodifiableList(customComponents);
mySupportLibraryComponents = ImmutableList.copyOf(supportLibraryComponents);
if (!myCustomComponents.isEmpty() || !mySupportLibraryComponents.isEmpty()) {
rebuild();
}
}
});
rebuild(false);
}
/**
* Rebuild the preview
* @param forceRepaint if true, a component repaint will be issued
*/
private void rebuild(boolean forceRepaint) {
try {
Configuration configuration = myContext.getConfiguration();
int minApiLevel = configuration.getTarget() != null ? configuration.getTarget().getVersion().getApiLevel() : Integer.MAX_VALUE;
ThemePreviewBuilder builder = new ThemePreviewBuilder()
.setBackgroundColor(getBackground()).addAllComponents(ThemePreviewBuilder.AVAILABLE_BASE_COMPONENTS)
.addAllComponents(myCustomComponents)
.addComponentFilter(new ThemePreviewBuilder.SearchFilter(mySearchTerm))
.addComponentFilter(new ThemePreviewBuilder.ApiLevelFilter(minApiLevel))
.addComponentFilter(myGroupFilter);
myIsAppCompatTheme = ThemeEditorUtils.isAppCompatTheme(configuration);
if (myIsAppCompatTheme) {
builder
.addComponentFilter(mySupportReplacementsFilter)
.addAllComponents(mySupportLibraryComponents);
}
myAndroidPreviewPanel.setDocument(builder.build());
if (forceRepaint) {
repaint();
}
}
catch (ParserConfigurationException e) {
LOG.error("Unable to generate dynamic theme preview", e);
}
}
/**
* Rebuild the preview and repaint
*/
private void rebuild() {
rebuild(true);
}
private void refreshConfiguration() {
Configuration configuration = myContext.getConfiguration();
myAndroidPreviewPanel.updateConfiguration(configuration);
// We want the preview to remain the same size even when the device being used to render is different.
// Adjust the scale to the current config.
if (configuration.getDeviceState() != null) {
double scale = myConstantScalingFactor / configuration.getDeviceState().getHardware().getScreen().getPixelDensity().getDpiValue();
myAndroidPreviewPanel.setScale(scale);
} else {
LOG.error("Configuration getDeviceState returned null. Unable to set preview scale.");
}
}
/**
* Sets the preview scale to allow zooming in and out. Even when zoom (scale != 1.0) is set, different devices will still render to the
* same size as the theme preview renderer is DPI independent.
*/
public void setScale(double scale) {
myConstantScalingFactor = DEFAULT_SCALING_FACTOR * scale;
}
/**
* Tells the panel that it needs to reload its android content and repaint it.
*/
public void invalidateGraphicsRenderer() {
myAndroidPreviewPanel.invalidateGraphicsRenderer();
}
// Implements RenderContext
// Only methods relevant to the configuration selection have been implemented
@Nullable
@Override
public Configuration getConfiguration() {
return myContext.getConfiguration();
}
@Override
public void setConfiguration(@NotNull Configuration configuration) {
// This method is used in the layout editor to support the multi-preview. The theme editor doesn't support it.
throw new UnsupportedOperationException("Configuration can not be changed on AndroidThemePreviewPanel");
}
@Override
public void requestRender() {
}
@NotNull
@Override
public UsageType getType() {
return UsageType.UNKNOWN;
}
@Nullable
@Override
public XmlFile getXmlFile() {
return null;
}
@Nullable
@Override
public VirtualFile getVirtualFile() {
return null;
}
@Nullable
@Override
public Module getModule() {
return null;
}
@Override
public boolean hasAlphaChannel() {
return false;
}
@NotNull
@Override
public Component getComponent() {
return this;
}
@NotNull
@Override
public Dimension getFullImageSize() {
return NO_SIZE;
}
@NotNull
@Override
public Dimension getScaledImageSize() {
return NO_SIZE;
}
@NotNull
@Override
public Rectangle getClientArea() {
return new Rectangle();
}
@Override
public boolean supportsPreviews() {
return false;
}
@Nullable
@Override
public RenderPreviewManager getPreviewManager(boolean createIfNecessary) {
return null;
}
@Override
public void setMaxSize(int width, int height) {
}
@Override
public void zoomFit(boolean onlyZoomOut, boolean allowZoomIn) {
}
@Override
public void updateLayout() {
}
@Override
public void setDeviceFramesEnabled(boolean on) {
}
@Nullable
@Override
public BufferedImage getRenderedImage() {
return null;
}
@Nullable
@Override
public RenderResult getLastResult() {
return null;
}
@Nullable
@Override
public RenderedViewHierarchy getViewHierarchy() {
return null;
}
}