com.android.tools.idea.rendering.multi.RenderPreview.java Source code

Java tutorial

Introduction

Here is the source code for com.android.tools.idea.rendering.multi.RenderPreview.java

Source

/*
 * Copyright (C) 2013 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.rendering.multi;

import com.android.ide.common.rendering.api.RenderSession;
import com.android.ide.common.rendering.api.Result;
import com.android.ide.common.rendering.api.Result.Status;
import com.android.ide.common.res2.ResourceFile;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.resources.Density;
import com.android.resources.ResourceType;
import com.android.resources.ScreenOrientation;
import com.android.sdklib.devices.Device;
import com.android.sdklib.devices.Screen;
import com.android.sdklib.devices.State;
import com.android.tools.idea.configurations.*;
import com.android.tools.idea.ddms.screenshot.DeviceArtPainter;
import com.android.tools.idea.rendering.*;
import com.android.utils.SdkUtils;
import com.intellij.icons.AllIcons;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiManager;
import com.intellij.psi.xml.XmlFile;
import com.intellij.util.PairFunction;
import com.intellij.util.ui.UIUtil;
import icons.AndroidIcons;
import org.jetbrains.android.facet.AndroidFacet;
import org.jetbrains.android.uipreview.AndroidLayoutPreviewToolWindowSettings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;
import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Comparator;

import static com.android.tools.idea.configurations.ConfigurationListener.MASK_RENDERING;
import static com.android.tools.idea.rendering.ShadowPainter.SMALL_SHADOW_SIZE;

/**
 * Represents a preview rendering of a given configuration
 */
public class RenderPreview implements Disposable {
    private static final Logger LOG = Logger.getInstance("#com.android.tools.idea.rendering.RenderPreview");

    /**
     * Height of the toolbar shown over a preview during hover. Needs to be
     * large enough to accommodate icons below.
     */
    private static final int HEADER_HEIGHT = 20;

    /** Whether these previews support zooming at the individual level */
    private static final boolean ZOOM_SUPPORT = false;

    /**
     * Whether to dump out rendering failures of the previews to the log
     */
    private static final boolean DUMP_RENDER_DIAGNOSTICS = true;

    /**
     * Extra error checking in debug mode
     */
    private static final boolean DEBUG = false;

    /**
     * The configuration being previewed
     */
    private @NotNull Configuration myConfiguration;

    /**
     * Configuration to use if we have an alternate input to be rendered
     */
    private @NotNull Configuration myAlternateConfiguration;

    /**
     * The associated manager
     */
    private final @NotNull RenderPreviewManager myManager;

    private final @NotNull RenderContext myRenderContext;
    private @Nullable BufferedImage myThumbnail;
    private @Nullable String myDisplayName;
    private int myX;
    private int myY;
    private int myLayoutWidth;
    private int myLayoutHeight;
    private int myTitleHeight;
    private double myScale = 1.0;
    private double myAspectRatio;
    /** Whether the preview wants a device frame (but it may still not show it if the option isc currently off) */
    private boolean myShowFrame;
    /** Whether current thumbnail actually has a device frame */
    private boolean myThumbnailHasFrame;
    private @Nullable Rectangle myViewBounds;

    private @Nullable Runnable myPendingRendering;

    /**
     * If non null, points to a separate file containing the source
     */
    private @Nullable VirtualFile myAlternateInput;

    /**
     * If included within another layout, the name of that outer layout
     */
    private @Nullable IncludeReference myIncludedWithin;

    /**
     * Whether the mouse is actively hovering over this preview
     */
    private boolean myActive;

    /**
     * Whether this preview cannot be rendered because of a model error - such
     * as an invalid configuration, a missing resource, an error in the XML
     * markup, etc. If non null, contains the error message (or a blank string
     * if not known), and null if the render was successful.
     */
    private String myError;

    /**
     * Whether in the current layout, this preview is visible
     */
    private boolean myVisible;

    /**
     * Whether the configuration has changed and needs to be refreshed the next time
     * this preview made visible. This corresponds to the change flags in
     * {@link ConfigurationListener}.
     */
    private int myDirty;

    /**
     * TODO: Figure out something more memory efficient than storing all these images. Maybe resize it down to half size initially!
     * Or maybe only if it's made setVisible
     */
    private BufferedImage myFullImage;
    private int myFullWidth;
    private int myFullHeight;

    /**
     * Creates a new {@linkplain RenderPreview}
     *
     * @param manager       the manager
     * @param renderContext canvas where preview is painted
     * @param configuration the associated configuration
     * @param showFrame     whether device frames should be shown
     */
    @SuppressWarnings("AssertWithSideEffects")
    private RenderPreview(@NotNull RenderPreviewManager manager, @NotNull RenderContext renderContext,
            @NotNull Configuration configuration, boolean showFrame) {
        myManager = manager;
        myRenderContext = renderContext;
        myConfiguration = configuration;
        myShowFrame = showFrame;

        // Should only attempt to create configurations for fully configured devices
        //noinspection AssertWithSideEffects
        assert myConfiguration.getDevice() != null;
        assert myConfiguration.getDeviceState() != null;
        assert myConfiguration.getLocale() != null;
        assert myConfiguration.getTarget() != null;
        assert myConfiguration.getTheme() != null;
        assert myConfiguration.getFullConfig().getScreenSizeQualifier() != null : myConfiguration;

        computeInitialSize();
    }

    /**
     * Considers the device screen and orientation and computes initial values for
     * the {@link #myFullWidth}, {@link #myFullHeight}, {@link #myAspectRatio},
     * {@link #myLayoutWidth} and {@link #myLayoutHeight} fields
     */
    void computeInitialSize() {
        computeFullSize();

        if (myFullHeight > 0) {
            double scale = getScale(myFullWidth, myFullHeight);
            myLayoutWidth = (int) (myFullWidth * scale);
            myLayoutHeight = (int) (myFullHeight * scale);
        } else {
            myAspectRatio = 1;
            myLayoutWidth = RenderPreviewManager.getMaxWidth();
            myLayoutHeight = RenderPreviewManager.getMaxHeight();
        }
    }

    /**
     * Considers the device screen and orientation and computes values for
     * the {@link #myFullWidth}, {@link #myFullHeight}, and {@link #myAspectRatio}.
     */
    @SuppressWarnings("SuspiciousNameCombination") // Deliberately swapping width/height orientations
    private boolean computeFullSize() {
        Device device = myConfiguration.getDevice();
        if (device == null) {
            return true;
        }
        Screen screen = device.getDefaultHardware().getScreen();
        if (screen == null) {
            return true;
        }

        State deviceState = myConfiguration.getDeviceState();
        if (deviceState == null) {
            deviceState = device.getDefaultState();
        }
        ScreenOrientation orientation = deviceState.getOrientation();
        Dimension size = device.getScreenSize(orientation);
        assert size != null;
        int screenWidth = size.width;
        int screenHeight = size.height;

        boolean changed = myFullWidth != screenWidth || myFullHeight != screenHeight;
        myFullWidth = screenWidth;
        myFullHeight = screenHeight;

        if (myShowFrame) {
            DeviceArtPainter framePainter = DeviceArtPainter.getInstance();
            double xScale = framePainter.getFrameWidthOverhead(device, orientation);
            double yScale = framePainter.getFrameHeightOverhead(device, orientation);
            myFullWidth *= xScale;
            myFullHeight *= yScale;
        }

        myAspectRatio = myFullHeight == 0 ? 1 : myFullWidth / (double) myFullHeight;
        return changed;
    }

    /** Recomputes the size */
    void updateSize() {
        boolean changed = computeFullSize();
        if (changed) {
            setMaxSize(myMaxWidth, myMaxHeight);
        }
    }

    /**
     * Sets the configuration to use for this preview
     *
     * @param configuration the new configuration
     */
    public void setConfiguration(@NotNull Configuration configuration) {
        myConfiguration = configuration;
    }

    /**
     * Gets the scale being applied to the thumbnail
     *
     * @return the scale being applied to the thumbnail
     */
    public double getScale() {
        return myScale;
    }

    /**
     * Sets the scale to apply to the thumbnail
     *
     * @param scale the factor to scale the thumbnail picture by
     */
    public void setScale(double scale) {
        if (ZOOM_SUPPORT) {
            if (scale != myScale) {
                disposeThumbnail();
                myScale = scale;
            }
        }
    }

    /**
     * Returns the aspect ratio of this render preview
     *
     * @return the aspect ratio
     */
    public double getAspectRatio() {
        return myAspectRatio;
    }

    /**
     * Returns whether the preview is actively hovered
     *
     * @return whether the mouse is hovering over the preview
     */
    public boolean isActive() {
        return myActive;
    }

    /**
     * Sets whether the preview is actively hovered
     *
     * @param active if the mouse is hovering over the preview
     */
    public void setActive(boolean active) {
        myActive = active;
    }

    /**
     * Returns whether the preview is visible. Previews that are off
     * screen are typically marked invisible during layout, which means we don't
     * have to expend effort computing preview thumbnails etc
     *
     * @return true if the preview is visible
     */
    public boolean isVisible() {
        return myVisible;
    }

    /**
     * Returns whether this preview represents a forked layout
     *
     * @return true if this preview represents a separate file
     */
    public boolean isForked() {
        return myAlternateInput != null || myIncludedWithin != null;
    }

    /**
     * Returns the file to be used for this preview, or null if this is not a
     * forked layout meaning that the file is the one used in the chooser
     *
     * @return the file or null for non-forked layouts
     */
    @Nullable
    public VirtualFile getAlternateInput() {
        if (myAlternateInput != null) {
            return myAlternateInput;
        } else if (myIncludedWithin != null) {
            return myIncludedWithin.getFile();
        }

        return null;
    }

    /**
     * Returns the area of this render preview, PRIOR to scaling
     *
     * @return the area (width times height without scaling)
     */
    int getArea() {
        return myLayoutWidth * myLayoutHeight;
    }

    /**
     * Sets whether the preview is visible. Previews that are off
     * screen are typically marked invisible during layout, which means we don't
     * have to expend effort computing preview thumbnails etc
     *
     * @param visible whether this preview is visible
     */
    public void setVisible(boolean visible) {
        if (visible != myVisible) {
            myVisible = visible;
            if (myVisible) {
                if (myDirty != 0) {
                    // Just made the render preview visible:
                    configurationChanged(myDirty); // schedules render
                } else {
                    updateForkStatus();
                    myManager.scheduleRender(this);
                }
            } else {
                dispose();
            }
        }
    }

    /**
     * Sets the layout position relative to the top left corner of the preview
     * area, in control coordinates
     */
    void setPosition(int x, int y) {
        myX = x;
        myY = y;
    }

    /**
     * Gets the layout X position relative to the top left corner of the preview
     * area, in control coordinates
     */
    int getX() {
        return myX;
    }

    /**
     * Gets the layout Y position relative to the top left corner of the preview
     * area, in control coordinates
     */
    int getY() {
        return myY;
    }

    /**
     * Determine whether this configuration has a better match in a different layout file
     */
    private void updateForkStatus() {
        FolderConfiguration config = myConfiguration.getFullConfig();
        if (myAlternateInput != null && myConfiguration.isBestMatchFor(myAlternateInput, config)) {
            return;
        }

        myAlternateInput = null;
        VirtualFile editedFile = myConfiguration.getFile();
        if (editedFile != null) {
            if (!myConfiguration.isBestMatchFor(editedFile, config)) {
                ProjectResources resources = ProjectResources.get(myConfiguration.getModule(), true);
                VirtualFile best = resources.getMatchingFile(editedFile, ResourceType.LAYOUT, config);
                if (best != null) {
                    myAlternateInput = best;
                }
                if (myAlternateInput != null) {
                    myAlternateConfiguration = Configuration.create(myConfiguration, myAlternateInput);
                }
            }
        }
    }

    /**
     * Creates a new {@linkplain RenderPreview}
     *
     * @param manager       the manager
     * @param configuration the associated configuration
     * @param showFrame     whether device frames should be shown
     * @return a new configuration
     */
    @NotNull
    public static RenderPreview create(@NotNull RenderPreviewManager manager, @NotNull Configuration configuration,
            boolean showFrame) {
        RenderContext context = manager.getRenderContext();
        return new RenderPreview(manager, context, configuration, showFrame);
    }

    /**
     * Throws away this preview: cancels any pending rendering jobs and disposes
     * of image resources etc
     */
    @Override
    public void dispose() {
        disposeThumbnail();
        if (this != myManager.getStashedPreview()) {
            myConfiguration.dispose();
        }
    }

    /**
     * Disposes the thumbnail rendering.
     */
    void disposeThumbnail() {
        myThumbnail = null;
        myFullImage = null;
    }

    /**
     * Returns the display name of this preview
     *
     * @return the name of the preview
     */
    @NotNull
    public String getDisplayName() {
        if (myDisplayName == null) {
            String displayName = getConfiguration().getDisplayName();
            if (displayName == null) {
                // No display name: this must be the configuration used by default
                // for the view which is originally displayed (before adding thumbnails),
                // and you've switched away to something else; now we need to display a name
                // for this original configuration. For now, just call it "Original"
                return "Original";
            }

            return displayName;
        }

        return myDisplayName;
    }

    /**
     * Sets the display name of this preview. By default, the display name is
     * the display name of the configuration, but it can be overridden by calling
     * this setter (which only sets the preview name, without editing the configuration.)
     *
     * @param displayName the new display name
     */
    public void setDisplayName(@Nullable String displayName) {
        myDisplayName = displayName;
    }

    /**
     * Sets an inclusion context to use for this layout, if any. This will render
     * the configuration preview as the outer layout with the current layout
     * embedded within.
     *
     * @param includedWithin a reference to a layout which includes this one
     */
    public void setIncludedWithin(@Nullable IncludeReference includedWithin) {
        myIncludedWithin = includedWithin;
    }

    /**
     * Render immediately (on the current thread)
     */
    void renderSync() {
        disposeThumbnail();

        final Module module = myRenderContext.getModule();
        if (module == null) {
            return;
        }
        AndroidFacet facet = AndroidFacet.getInstance(module);
        if (facet == null) {
            return;
        }

        final Configuration configuration = myAlternateInput != null && myAlternateConfiguration != null
                ? myAlternateConfiguration
                : myConfiguration;
        PsiFile psiFile;
        if (myAlternateInput != null) {
            psiFile = ApplicationManager.getApplication().runReadAction(new Computable<PsiFile>() {
                @Nullable
                @Override
                public PsiFile compute() {
                    return PsiManager.getInstance(module.getProject()).findFile(myAlternateInput);
                }
            });
        } else {
            psiFile = myRenderContext.getXmlFile();
        }
        if (psiFile == null) {
            return;
        }
        RenderLogger logger = new RenderLogger(psiFile.getName(), module);
        PreviewRenderContext renderContext = new PreviewRenderContext(myRenderContext, configuration,
                (XmlFile) psiFile);
        final RenderService renderService = RenderService.create(facet, module, psiFile, configuration, logger,
                renderContext);
        if (renderService == null) {
            return;
        }

        if (myIncludedWithin != null) {
            renderService.setIncludedWithin(myIncludedWithin);
        }

        // Fetch outside of read lock
        configuration.getResourceResolver();

        RenderResult result = ApplicationManager.getApplication().runReadAction(new Computable<RenderResult>() {
            @Nullable
            @Override
            public RenderResult compute() {
                return renderService.render();
            }
        });

        RenderSession session = result.getSession();
        if (session != null) {
            Result render = session.getResult();

            if (DUMP_RENDER_DIAGNOSTICS) {
                if (logger.hasProblems() || !session.getResult().isSuccess()) {
                    RenderErrorPanel panel = new RenderErrorPanel();
                    String html = panel.showErrors(result);
                    LOG.info("Found problems rendering preview " + getDisplayName() + ": " + html);
                }
            }

            if (render.isSuccess()) {
                myError = null;
            } else {
                myError = render.getErrorMessage();
                if (myError == null) {
                    myError = "<unknown error>";
                }
            }

            if (render.getStatus() == Status.ERROR_TIMEOUT) {
                // TODO: Special handling? schedule update again later
                return;
            }

            if (render.isSuccess()) {
                ScalableImage scalableImage = result.getImage();
                if (scalableImage != null) {
                    myFullImage = scalableImage.getOriginalImage();
                }
            }

            if (myError != null) {
                createErrorThumbnail();
            }
        } else {
            myError = "Render Failed";
            createErrorThumbnail();
        }
    }

    @Nullable
    private BufferedImage getThumbnail() {
        if (myThumbnail == null && myFullImage != null) {
            createThumbnail();
        }

        return myThumbnail;
    }

    /**
     * Sets the new image of the preview and generates a thumbnail
     */
    void createThumbnail() {
        BufferedImage image = myFullImage;
        if (image == null) {
            myThumbnail = null;
            return;
        }

        int shadowSize = 0;
        myThumbnailHasFrame = false;
        boolean showFrame = myShowFrame;

        Project project = myConfiguration.getModule().getProject();
        AndroidLayoutPreviewToolWindowSettings.GlobalState settings = AndroidLayoutPreviewToolWindowSettings
                .getInstance(project).getGlobalState();
        if (showFrame && settings.isShowDeviceFrames()) {
            DeviceArtPainter framePainter = DeviceArtPainter.getInstance();
            Device device = myConfiguration.getDevice();
            boolean showEffects = settings.isShowEffects();
            State deviceState = myConfiguration.getDeviceState();
            if (device != null && deviceState != null) {
                double scale = getLayoutWidth() / (double) image.getWidth();
                ScreenOrientation orientation = deviceState.getOrientation();
                double frameScale = framePainter.getFrameMaxOverhead(device, orientation);
                scale /= frameScale;
                if (myViewBounds == null) {
                    myViewBounds = new Rectangle();
                }
                image = framePainter.createFrame(image, device, orientation, showEffects, scale, myViewBounds);
                myThumbnailHasFrame = true;
            } else {
                // TODO: Do drop shadow painting if frame fails?
                double scale = getLayoutWidth() / (double) image.getWidth();
                image = ImageUtils.scale(image, scale, scale, 0, 0);
            }
        } else {
            boolean drawShadows = !myRenderContext.hasAlphaChannel();
            double scale = getLayoutWidth() / (double) image.getWidth();
            shadowSize = drawShadows ? SMALL_SHADOW_SIZE : 0;
            if (scale < 1.0) {
                image = ImageUtils.scale(image, scale, scale, shadowSize, shadowSize);
                if (drawShadows) {
                    ShadowPainter.drawSmallRectangleShadow(image, 0, 0, image.getWidth() - shadowSize,
                            image.getHeight() - shadowSize);
                }
            }
        }

        myThumbnail = image;
        if (image != null) {
            myLayoutWidth = image.getWidth() - shadowSize;
            myLayoutHeight = image.getHeight() - shadowSize;
        }
    }

    void createErrorThumbnail() {
        int width = getLayoutWidth();
        int height = getLayoutHeight();
        BufferedImage image = new BufferedImage(width + SMALL_SHADOW_SIZE, height + SMALL_SHADOW_SIZE,
                BufferedImage.TYPE_INT_ARGB);

        Graphics2D g = image.createGraphics();
        g.setColor(new Color(0xfffbfcc6));
        g.fillRect(0, 0, width, height);

        g.dispose();

        boolean drawShadows = !myRenderContext.hasAlphaChannel();
        if (drawShadows) {
            ShadowPainter.drawSmallRectangleShadow(image, 0, 0, image.getWidth() - SMALL_SHADOW_SIZE,
                    image.getHeight() - SMALL_SHADOW_SIZE);
        }

        myThumbnail = image;
    }

    private static double getScale(int width, int height) {
        int maxWidth = RenderPreviewManager.getMaxWidth();
        int maxHeight = RenderPreviewManager.getMaxHeight();
        if (width > 0 && height > 0 && (width > maxWidth || height > maxHeight)) {
            if (width >= height) { // landscape
                return maxWidth / (double) width;
            } else { // portrait
                return maxHeight / (double) height;
            }
        }

        return 1.0;
    }

    /**
     * Returns the width of the preview, in pixels
     *
     * @return the width in pixels
     */
    public int getWidth() {
        return (int) (myLayoutWidth * myScale * RenderPreviewManager.getScale());
    }

    /**
     * Returns the height of the preview, in pixels
     *
     * @return the height in pixels
     */
    public int getHeight() {
        return (int) (myLayoutHeight * myScale * RenderPreviewManager.getScale());
    }

    /**
     * Returns the <b>desired</b> width of this preview, in pixels.
     * Whereas {@link #getWidth()} returns the current width of the preview,
     * this method returns the desired with after the next render.
     * <p>
     * For example, let's say the orientation has just changed and an update has
     * been scheduled. During this interval, the width of the preview is the
     * old, un-rotated preview's width, whereas the layout width is the new
     * width after rotation has been applied.
     *
     * @return the layout width
     */
    public int getLayoutWidth() {
        return myLayoutWidth;
    }

    /**
     * Returns the <b>desired</b> height of this preview, in pixels.
     * See {@link #getLayoutWidth()} for details on how the layout height
     * is different from {@link #getHeight()}.
     */
    public int getLayoutHeight() {
        return myLayoutHeight;
    }

    /**
     * Handles clicks within the preview (x and y are positions relative within the
     * preview
     *
     * @param x the x coordinate within the preview where the click occurred
     * @param y the y coordinate within the preview where the click occurred
     * @return true if this preview handled (and therefore consumed) the click
     */
    public boolean click(int x, int y) {
        if (y >= myTitleHeight && y < myTitleHeight + HEADER_HEIGHT) {
            int left = 0;
            left += AllIcons.Actions.CloseNewHovered.getIconWidth();
            if (x <= left) {
                // Delete
                myManager.deletePreview(this);
                return true;
            }
            if (ZOOM_SUPPORT) {
                left += AndroidIcons.ZoomIn.getIconWidth();
                if (x <= left) {
                    // Zoom in
                    myScale *= (1 / 0.5);
                    if (Math.abs(myScale - 1.0) < 0.0001) {
                        myScale = 1.0;
                    }

                    myManager.scheduleRender(this, 0);
                    myManager.layout(true);
                    myManager.redraw();
                    return true;
                }
                left += AndroidIcons.ZoomOut.getIconWidth();
                if (x <= left) {
                    // Zoom out
                    myScale *= (0.5 / 1);
                    if (Math.abs(myScale - 1.0) < 0.0001) {
                        myScale = 1.0;
                    }
                    myManager.scheduleRender(this, 0);

                    myManager.layout(true);
                    myManager.redraw();
                    return true;
                }
            }
            left += AllIcons.Actions.Edit.getIconWidth();
            if (x <= left) {
                // Edit. For now, just rename
                Project project = myConfiguration.getConfigurationManager().getProject();
                String newName = Messages.showInputDialog(project, "Name:", "Rename Preview", null,
                        myConfiguration.getDisplayName(), null);
                if (newName != null) {
                    myConfiguration.setDisplayName(newName);
                    myManager.redraw();
                }

                return true;
            }

            // Clicked anywhere else on header
            // Perhaps open Edit dialog here?
        }

        myManager.switchTo(this);
        return true;
    }

    /**
     * Paints the preview at the given x/y position
     *
     * @param gc the graphics context to paint it into
     * @param x  the x coordinate to paint the preview at
     * @param y  the y coordinate to paint the preview at
     */
    void paint(Graphics2D gc, int x, int y) {
        myTitleHeight = paintTitle(gc, x, y, true /*showFile*/);
        y += myTitleHeight;
        y += 2;

        Component component = myRenderContext.getComponent();
        gc.setFont(UIUtil.getToolTipFont());
        FontMetrics fontMetrics = gc.getFontMetrics();
        int fontHeight = fontMetrics.getHeight();
        int fontBaseline = fontHeight - fontMetrics.getDescent();

        int width = getWidth();
        int height = getHeight();
        BufferedImage thumbnail = getThumbnail();
        if (thumbnail != null && myError == null) {
            gc.drawImage(thumbnail, x, y, null);

            if (myActive) {
                // TODO: Can I figure out the actual frame bounds again?
                int x1 = x;
                int y1 = y;
                int w = myLayoutWidth;
                int h = myLayoutHeight;

                if (myThumbnailHasFrame && myViewBounds != null) {
                    x1 = myViewBounds.x + x1;
                    y1 = myViewBounds.y + y1;
                    w = myViewBounds.width;
                    h = myViewBounds.height;
                }

                gc.setColor(new Color(181, 213, 255));
                gc.drawRect(x1 - 1, y1 - 1, w + 1, h + 1);
                gc.drawRect(x1 - 2, y1 - 2, w + 3, h + 3);
                gc.drawRect(x1 - 3, y1 - 3, w + 5, h + 5);
            }
        } else if (myError != null && !myError.isEmpty()) {
            if (thumbnail != null) {
                gc.drawImage(thumbnail, x, y, null);
            } else {
                gc.setColor(Color.DARK_GRAY);
                gc.drawRect(x, y, width, height);
            }

            Shape prevClip = gc.getClip();
            gc.setClip(x, y, width, height);
            Icon icon = AndroidIcons.RenderError;

            icon.paintIcon(component, gc, x + (width - icon.getIconWidth()) / 2,
                    y + (height - icon.getIconHeight()) / 2);
            Composite prevComposite = gc.getComposite();
            gc.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.8f));
            gc.setColor(Color.WHITE);
            gc.fillRect(x, y, width, height);
            gc.setComposite(prevComposite);

            String msg = myError;
            Density density = myConfiguration.getDensity();
            if (density == Density.TV || density == Density.LOW) {
                msg = "Broken rendering library; unsupported DPI. Try using the SDK manager "
                        + "to get updated layout libraries.";
            }
            int charWidth = fontMetrics.charWidth('x');
            int charsPerLine = (width - 10) / charWidth;
            msg = SdkUtils.wrap(msg, charsPerLine, null);
            gc.setColor(Color.BLACK);
            gc.setFont(UIUtil.getToolTipFont());
            UIUtil.applyRenderingHints(gc);
            final UIUtil.TextPainter painter = new UIUtil.TextPainter().withShadow(true).withLineSpacing(1.4f);
            for (String line : msg.split("\n")) {
                painter.appendLine(line);
            }
            final int xf = x + 5;
            final int yf = y + HEADER_HEIGHT + fontBaseline;
            painter.draw(gc, new PairFunction<Integer, Integer, Pair<Integer, Integer>>() {
                @Override
                public Pair<Integer, Integer> fun(Integer width, Integer height) {
                    return Pair.create(xf, yf);
                }
            });
            gc.setClip(prevClip);
        } else {
            gc.setColor(Color.DARK_GRAY);
            gc.drawRect(x, y, width, height);
            Icon icon = AndroidIcons.RefreshPreview;
            Composite prevComposite = gc.getComposite();
            gc.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.35f));
            icon.paintIcon(component, gc, x + (width - icon.getIconWidth()) / 2,
                    y + (height - icon.getIconHeight()) / 2);
            gc.setComposite(prevComposite);
        }

        if (myActive && !myShowFrame) {
            int left = x;

            Composite prevComposite = gc.getComposite();
            gc.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.8f));
            gc.setColor(Color.WHITE);
            gc.fillRect(left, y, x + width - left, HEADER_HEIGHT);

            y += 2;

            // Paint icons
            AllIcons.Actions.CloseNewHovered.paintIcon(component, gc, left, y);
            left += AllIcons.Actions.CloseNewHovered.getIconWidth();

            if (ZOOM_SUPPORT) {
                AndroidIcons.ZoomIn.paintIcon(component, gc, left, y);
                left += AndroidIcons.ZoomIn.getIconWidth();

                AndroidIcons.ZoomOut.paintIcon(component, gc, left, y);
                left += AndroidIcons.ZoomOut.getIconWidth();
            }

            AllIcons.Actions.Edit.paintIcon(component, gc, left, y);
            left += AllIcons.Actions.Edit.getIconWidth();

            gc.setComposite(prevComposite);
        }
    }

    /**
     * Paints the preview title at the given position (and returns the required
     * height)
     *
     * @param gc the graphics context to paint into
     * @param x  the left edge of the preview rectangle
     * @param y  the top edge of the preview rectangle
     */
    private int paintTitle(Graphics2D gc, int x, int y, boolean showFile) {
        String displayName = getDisplayName();
        return paintTitle(gc, x, y, showFile, displayName);
    }

    /**
     * Paints the preview title at the given position (and returns the required
     * height)
     *
     * @param gc          the graphics context to paint into
     * @param x           the left edge of the preview rectangle
     * @param y           the top edge of the preview rectangle
     * @param displayName the title string to be used
     */
    int paintTitle(Graphics2D gc, int x, int y, boolean showFile, String displayName) {
        int titleHeight = 0;

        if (showFile && myIncludedWithin != null) {
            if (myManager.getMode() != RenderPreviewMode.INCLUDES) {
                displayName = "<include>";
            } else {
                // Skip: just paint footer instead
                displayName = null;
            }
        }

        int labelTop = y + 1;
        Shape prevClip = gc.getClip();
        Rectangle clipBounds = prevClip.getBounds();
        int clipWidth = myMaxWidth > 0 ? myMaxWidth : myLayoutWidth;
        gc.setClip(x, labelTop, Math.min(clipWidth, clipBounds.x + clipBounds.width - x),
                Math.min(100, clipBounds.y + clipBounds.height - labelTop));

        // Use font height rather than extent height since we want two adjacent
        // previews (which may have different display names and therefore end
        // up with slightly different extent heights) to have identical title
        // heights such that they are aligned identically
        gc.setFont(UIUtil.getToolTipFont());
        FontMetrics fontMetrics = gc.getFontMetrics();
        int fontHeight = fontMetrics.getHeight();
        int fontBaseline = fontHeight - fontMetrics.getDescent();

        if (displayName != null && displayName.length() > 0) {
            // Deliberately using Color.WHITE rather than JBColor.WHITE here: the background in the preview render
            // is always gray and does not vary by theme
            gc.setColor(Color.WHITE);
            Rectangle2D extent = fontMetrics.getStringBounds(displayName, gc);
            int labelLeft = Math.max(x, x + (myLayoutWidth - (int) extent.getWidth()) / 2);
            Icon icon = null;
            Locale locale = myConfiguration.getLocale();
            if ((locale.hasLanguage() || locale.hasRegion()) && (!(myConfiguration instanceof NestedConfiguration)
                    || ((NestedConfiguration) myConfiguration).isOverridingLocale())) {
                icon = locale.getFlagImage();
            }

            if (icon != null) {
                int flagWidth = icon.getIconWidth();
                int flagHeight = icon.getIconHeight();
                labelLeft = Math.max(x + flagWidth / 2, labelLeft);
                icon.paintIcon(myRenderContext.getComponent(), gc, labelLeft - flagWidth / 2 - 1,
                        labelTop + (fontHeight - flagHeight) / 2);
                labelLeft += flagWidth / 2 + 1;
                gc.drawString(displayName, labelLeft, labelTop - (fontHeight - flagHeight) / 2 + fontBaseline);
            } else {
                gc.drawString(displayName, labelLeft, labelTop + fontBaseline);
            }

            labelTop += (int) extent.getHeight();
            titleHeight += fontHeight;
        }

        if (showFile && (myAlternateInput != null || myIncludedWithin != null)) {
            // Draw file flag, and parent folder name
            VirtualFile file = myAlternateInput != null ? myAlternateInput : myIncludedWithin.getFile();
            assert file != null;
            String fileName = file.getParent().getName() + File.separator + file.getName();
            Rectangle2D extent = fontMetrics.getStringBounds(fileName, gc);
            Icon icon = AllIcons.FileTypes.Xml;
            int iconWidth = icon.getIconWidth();
            int iconHeight = icon.getIconHeight();

            int labelLeft = Math.max(x, x + (myLayoutWidth - (int) extent.getWidth() - iconWidth - 1) / 2);
            icon.paintIcon(myRenderContext.getComponent(), gc, labelLeft, labelTop);

            // Deliberately using Color.DARK_GRAY rather than JBColor.GRAY here: the background in the preview render
            // is always gray and does not vary by theme
            gc.setColor(Color.DARK_GRAY);
            labelLeft += iconWidth + 1;
            labelTop -= ((int) extent.getHeight() - iconHeight) / 2;
            gc.drawString(fileName, labelLeft, labelTop + fontBaseline);

            titleHeight += Math.max(titleHeight, icon.getIconHeight());
        }

        gc.setClip(prevClip);

        return titleHeight;
    }

    /**
     * Notifies that the preview's configuration has changed.
     *
     * @param flags the change flags, a bitmask corresponding to the
     *              {@code CHANGE_} constants in {@link ConfigurationListener}
     */
    public void configurationChanged(int flags) {
        if (!myVisible) {
            myDirty |= flags;
            return;
        }
        if ((flags & MASK_RENDERING) != 0) {
            updateForkStatus();
        }

        // Sanity check to make sure things are working correctly
        if (DEBUG) {
            RenderPreviewMode mode = myManager.getMode();
            Configuration configuration = myRenderContext.getConfiguration();
            if (mode == RenderPreviewMode.DEFAULT) {
                assert myConfiguration instanceof VaryingConfiguration;
                VaryingConfiguration config = (VaryingConfiguration) myConfiguration;
                int alternateFlags = config.getAlternateFlags();
                switch (alternateFlags) {
                case ConfigurationListener.CFG_DEVICE_STATE: {
                    State configState = config.getDeviceState();
                    State chooserState = configuration.getDeviceState();
                    assert configState != null && chooserState != null;
                    assert !configState.getName().equals(chooserState.getName()) : configState.toString() + ':'
                            + chooserState;

                    Device configDevice = config.getDevice();
                    Device chooserDevice = configuration.getDevice();
                    assert configDevice != null && chooserDevice != null;
                    assert configDevice == chooserDevice : configDevice.toString() + ':' + chooserDevice;

                    break;
                }
                case ConfigurationListener.CFG_DEVICE: {
                    Device configDevice = config.getDevice();
                    Device chooserDevice = configuration.getDevice();
                    assert configDevice != null && chooserDevice != null;
                    assert configDevice != chooserDevice : configDevice.toString() + ':' + chooserDevice;

                    State configState = config.getDeviceState();
                    State chooserState = configuration.getDeviceState();
                    assert configState != null && chooserState != null;
                    assert configState.getName().equals(chooserState.getName()) : configState.toString() + ':'
                            + chooserState;

                    break;
                }
                case ConfigurationListener.CFG_LOCALE: {
                    Locale configLocale = config.getLocale();
                    Locale chooserLocale = configuration.getLocale();
                    assert configLocale != null && chooserLocale != null;
                    assert configLocale != chooserLocale : configLocale.toString() + ':' + chooserLocale;
                    break;
                }
                default: {
                    // Some other type of override I didn't anticipate
                    assert false : alternateFlags;
                }
                }
            }
        }

        myDirty = 0;
        myManager.scheduleRender(this);
    }

    /**
     * Returns the configuration associated with this preview
     *
     * @return the configuration
     */
    @NotNull
    public Configuration getConfiguration() {
        return myConfiguration;
    }

    /**
     * Sets the input file to use for rendering. If not set, this will just be
     * the same file as the configuration chooser. This is used to render other
     * layouts, such as variations of the currently edited layout, which are
     * not kept in sync with the main layout.
     *
     * @param file the file to set as input
     */
    public void setAlternateInput(@Nullable VirtualFile file) {
        myAlternateInput = file;
    }

    @Override
    public String toString() {
        return getDisplayName() + ':' + myConfiguration;
    }

    /**
     * Sorts render previews into increasing aspect ratio order
     */
    static Comparator<RenderPreview> INCREASING_ASPECT_RATIO = new Comparator<RenderPreview>() {
        @Override
        public int compare(RenderPreview preview1, RenderPreview preview2) {
            return (int) Math.signum(preview1.myAspectRatio - preview2.myAspectRatio);
        }
    };

    /**
     * Sorts render previews into decreasing aspect ratio order
     */
    static Comparator<RenderPreview> DECREASING_ASPECT_RATIO = new Comparator<RenderPreview>() {
        @Override
        public int compare(RenderPreview preview1, RenderPreview preview2) {
            return (int) Math.signum(preview2.myAspectRatio - preview1.myAspectRatio);
        }
    };

    /**
     * Sorts render previews into visual order: row by row, column by column
     */
    static Comparator<RenderPreview> VISUAL_ORDER = new Comparator<RenderPreview>() {
        @Override
        public int compare(RenderPreview preview1, RenderPreview preview2) {
            int delta = preview1.myY - preview2.myY;
            if (delta == 0) {
                delta = preview1.myX - preview2.myX;
            }
            return delta;
        }
    };

    private int myMaxWidth;
    private int myMaxHeight;

    public void setMaxSize(int width, int height) {
        myMaxWidth = width;
        myMaxHeight = height;

        if (width == 0 || height == 0) {
            computeInitialSize();
        } else {
            double scale = Math.min(width / (double) myFullWidth,
                    (height - RenderPreviewManager.TITLE_HEIGHT) / (double) myFullHeight);
            myLayoutWidth = (int) (myFullWidth * scale);
            myLayoutHeight = (int) (myFullHeight * scale);
        }

        if (myThumbnail != null && (Math.abs(myLayoutWidth - myThumbnail.getWidth()) > 1)) {
            // Note that we null out myThumbnail, we *don't* call disposeThumbnail because we
            // want to reuse the large rendering and just scale it down again
            myThumbnail = null;
        }
    }

    public int getMaxWidth() {
        return myMaxWidth;
    }

    public int getMaxHeight() {
        return myMaxHeight;
    }

    /** Returns the current pending rendering request, if any */
    @Nullable
    public Runnable getPendingRendering() {
        return myPendingRendering;
    }

    /** Sets or clears the current pending rendering request */
    public void setPendingRendering(@Nullable Runnable pendingRendering) {
        myPendingRendering = pendingRendering;
    }

    public boolean isShowFrame() {
        return myShowFrame;
    }

    public void setShowFrame(boolean showFrame) {
        myShowFrame = showFrame;
    }
}