/* * 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.HardwareConfigHelper; 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.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.AndroidPsiUtils; 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.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.Couple; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; 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.AndroidEditorSettings; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.awt.*; import java.awt.geom.Ellipse2D; 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; import static java.awt.RenderingHints.KEY_ANTIALIASING; import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; /** * 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; private @Nullable String myId; /** * 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.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 = Math.min(1, 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.getFromFile(); } 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)) { LocalResourceRepository resources = AppResourceRepository.getAppResources(myConfiguration.getModule(), true); if (resources != null) { 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() { if (!tryRenderSync()) { disposeThumbnail(); } } private boolean tryRenderSync() { final Module module = myRenderContext.getModule(); if (module == null) { return false; } AndroidFacet facet = AndroidFacet.getInstance(module); if (facet == null) { return false; } final Configuration configuration = myAlternateInput != null && myAlternateConfiguration != null ? myAlternateConfiguration : myConfiguration; PsiFile psiFile; if (myAlternateInput != null) { psiFile = AndroidPsiUtils.getPsiFileSafely(module.getProject(), myAlternateInput); } else { psiFile = myRenderContext.getXmlFile(); } if (psiFile == null) { return false; } 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 false; } if (myIncludedWithin != null) { renderService.setIncludedWithin(myIncludedWithin); } RenderResult result = renderService.render(); RenderSession session = result != null ? result.getSession() : null; 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 false; } disposeThumbnail(); if (render.isSuccess()) { RenderedImage renderedImage = result.getImage(); if (renderedImage != null) { myFullImage = renderedImage.getOriginalImage(); } } if (myError != null) { createErrorThumbnail(); } return true; } else { myError = "Render Failed"; disposeThumbnail(); createErrorThumbnail(); return false; } } @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; } Project project = myConfiguration.getModule().getProject(); AndroidEditorSettings.GlobalState settings = AndroidEditorSettings.getInstance().getGlobalState(); if (UIUtil.isRetina() && ImageUtils.supportsRetina() && settings.isRetina() && createRetinaThumbnail()) { return; } int shadowSize = 0; myThumbnailHasFrame = false; boolean showFrame = myShowFrame; 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 = Math.min(1, getLayoutWidth() / (double)image.getWidth()); //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 = Math.min(1, getLayoutWidth() / (double)image.getWidth()); image = ImageUtils.scale(image, scale, scale, 0, 0); } } else { boolean drawShadows = !myRenderContext.hasAlphaChannel(); double scale = Math.min(1, 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; } } private boolean createRetinaThumbnail() { BufferedImage image = myFullImage; if (image == null) { myThumbnail = null; return true; } myThumbnailHasFrame = false; boolean showFrame = myShowFrame; AndroidEditorSettings.GlobalState settings = AndroidEditorSettings.getInstance().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, 2 * scale, myViewBounds); myViewBounds.x /= 2; myViewBounds.y /= 2; myViewBounds.width /= 2; myViewBounds.height /= 2; myThumbnailHasFrame = true; } else { double scale = getLayoutWidth() / (double)image.getWidth(); image = ImageUtils.scale(image, 2 * scale, 2 * scale, 0, 0); } image = ImageUtils.convertToRetina(image); if (image == null) { return false; } } else { boolean drawShadows = !myRenderContext.hasAlphaChannel(); double scale = getLayoutWidth() / (double)image.getWidth(); if (scale < 1.0) { image = ImageUtils.scale(image, 2 * scale, 2 * scale); image = ImageUtils.convertToRetina(image); if (image == null) { return false; } myLayoutWidth = image.getWidth(); myLayoutHeight = image.getHeight(); if (drawShadows) { image = ShadowPainter.createSmallRectangularDropShadow(image); } myThumbnail = image; return true; } } myThumbnail = image; myLayoutWidth = image.getWidth(); myLayoutHeight = image.getHeight(); return true; } void createErrorThumbnail() { int width = getLayoutWidth(); int height = getLayoutHeight(); @SuppressWarnings("UndesirableClassUsage") BufferedImage image = new BufferedImage(width + SMALL_SHADOW_SIZE, height + SMALL_SHADOW_SIZE, BufferedImage.TYPE_INT_ARGB); Graphics2D g = image.createGraphics(); //noinspection UseJBColor 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) { UIUtil.drawImage(gc, 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; } //noinspection UseJBColor gc.setColor(new Color(181, 213, 255)); if (HardwareConfigHelper.isRound(myConfiguration.getDevice())) { Stroke prevStroke = gc.getStroke(); gc.setStroke(new BasicStroke(3.0f)); Object prevAntiAlias = gc.getRenderingHint(KEY_ANTIALIASING); gc.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); Ellipse2D.Double ellipse = new Ellipse2D.Double(x1, y1, w, h); gc.draw(ellipse); gc.setStroke(prevStroke); gc.setRenderingHint(KEY_ANTIALIASING, prevAntiAlias); } else { 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) { UIUtil.drawImage(gc, thumbnail, x, y, null); } else { //noinspection UseJBColor 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)); //noinspection UseJBColor 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); //noinspection UseJBColor 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, Couple<Integer>>() { @Override public Couple<Integer> fun(Integer width, Integer height) { return Couple.of(xf, yf); } }); gc.setClip(prevClip); } else { //noinspection UseJBColor 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)); //noinspection UseJBColor 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 //noinspection UseJBColor 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.getFromFile(); 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 //noinspection UseJBColor 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(1, 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() / // No, only for scalable image! /* (ImageUtils.isRetinaImage(myThumbnail) ? 2 :*/ 1/*)*/) > 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; } } @Nullable public String getId() { return myId; } public void setId(@Nullable String id) { myId = id; } 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; } }