1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Eclipse Public License, Version 1.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.eclipse.org/org/documents/epl-v10.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.ide.eclipse.adt.internal.editors.layout.gle2; 18 19 import static com.android.ide.common.layout.LayoutConstants.FQCN_DATE_PICKER; 20 import static com.android.ide.common.layout.LayoutConstants.FQCN_EXPANDABLE_LIST_VIEW; 21 import static com.android.ide.common.layout.LayoutConstants.FQCN_LIST_VIEW; 22 import static com.android.ide.common.layout.LayoutConstants.FQCN_TIME_PICKER; 23 import static com.android.ide.eclipse.adt.AdtConstants.DOT_PNG; 24 25 import com.android.ide.common.rendering.LayoutLibrary; 26 import com.android.ide.common.rendering.api.Capability; 27 import com.android.ide.common.rendering.api.RenderSession; 28 import com.android.ide.common.rendering.api.ResourceValue; 29 import com.android.ide.common.rendering.api.StyleResourceValue; 30 import com.android.ide.common.rendering.api.ViewInfo; 31 import com.android.ide.common.rendering.api.SessionParams.RenderingMode; 32 import com.android.ide.common.resources.ResourceResolver; 33 import com.android.ide.eclipse.adt.AdtPlugin; 34 import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; 35 import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; 36 import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditor; 37 import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor; 38 import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; 39 import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository.RenderMode; 40 import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; 41 import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; 42 import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; 43 import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; 44 import com.android.sdklib.IAndroidTarget; 45 import com.android.util.Pair; 46 47 import org.eclipse.core.runtime.IPath; 48 import org.eclipse.core.runtime.IStatus; 49 import org.eclipse.jface.resource.ImageDescriptor; 50 import org.eclipse.swt.graphics.RGB; 51 import org.w3c.dom.Document; 52 import org.w3c.dom.Element; 53 import org.w3c.dom.Node; 54 import org.w3c.dom.NodeList; 55 56 import java.awt.image.BufferedImage; 57 import java.io.BufferedInputStream; 58 import java.io.File; 59 import java.io.FileInputStream; 60 import java.io.IOException; 61 import java.io.InputStream; 62 import java.net.MalformedURLException; 63 import java.util.ArrayList; 64 import java.util.Collections; 65 import java.util.List; 66 import java.util.Properties; 67 68 import javax.imageio.ImageIO; 69 70 /** 71 * Factory which can provide preview icons for android views of a particular SDK and 72 * editor's configuration chooser 73 */ 74 public class PreviewIconFactory { 75 private PaletteControl mPalette; 76 private RGB mBackground; 77 private RGB mForeground; 78 private File mImageDir; 79 80 private static final String PREVIEW_INFO_FILE = "preview.properties"; //$NON-NLS-1$ 81 82 public PreviewIconFactory(PaletteControl palette) { 83 mPalette = palette; 84 } 85 86 /** 87 * Resets the state in the preview icon factory such that it will re-fetch information 88 * like the theme and SDK (the icons themselves are cached in a directory across IDE 89 * session though) 90 */ 91 public void reset() { 92 mImageDir = null; 93 mBackground = null; 94 mForeground = null; 95 } 96 97 /** 98 * Deletes all the persistent state for the current settings such that it will be regenerated 99 */ 100 public void refresh() { 101 File imageDir = getImageDir(false); 102 if (imageDir != null && imageDir.exists()) { 103 File[] files = imageDir.listFiles(); 104 for (File file : files) { 105 file.delete(); 106 } 107 imageDir.delete(); 108 reset(); 109 } 110 } 111 112 /** 113 * Returns an image descriptor for the given element descriptor, or null if no image 114 * could be computed. The rendering parameters (SDK, theme etc) correspond to those 115 * stored in the associated palette. 116 * 117 * @param desc the element descriptor to get an image for 118 * @return an image descriptor, or null if no image could be rendered 119 */ 120 public ImageDescriptor getImageDescriptor(ElementDescriptor desc) { 121 File imageDir = getImageDir(false); 122 if (!imageDir.exists()) { 123 render(); 124 } 125 File file = new File(imageDir, getFileName(desc)); 126 if (file.exists()) { 127 try { 128 return ImageDescriptor.createFromURL(file.toURI().toURL()); 129 } catch (MalformedURLException e) { 130 AdtPlugin.log(e, "Could not create image descriptor for %s", file); 131 } 132 } 133 134 return null; 135 } 136 137 /** 138 * Partition the elements in the document according to their rendering preferences; 139 * elements that should be skipped are removed, elements that should be rendered alone 140 * are placed in their own list, etc 141 * 142 * @param document the document containing render fragments for the various elements 143 * @return 144 */ 145 private List<List<Element>> partitionRenderElements(Document document) { 146 List<List<Element>> elements = new ArrayList<List<Element>>(); 147 148 List<Element> shared = new ArrayList<Element>(); 149 Element root = document.getDocumentElement(); 150 elements.add(shared); 151 152 ViewMetadataRepository repository = ViewMetadataRepository.get(); 153 154 NodeList children = root.getChildNodes(); 155 for (int i = 0, n = children.getLength(); i < n; i++) { 156 Node node = children.item(i); 157 if (node.getNodeType() == Node.ELEMENT_NODE) { 158 Element element = (Element) node; 159 String fqn = repository.getFullClassName(element); 160 assert fqn.length() > 0 : element.getNodeName(); 161 RenderMode renderMode = repository.getRenderMode(fqn); 162 163 // Temporary special cases 164 if (fqn.equals(FQCN_LIST_VIEW) || fqn.equals(FQCN_EXPANDABLE_LIST_VIEW)) { 165 if (!mPalette.getEditor().renderingSupports(Capability.ADAPTER_BINDING)) { 166 renderMode = RenderMode.SKIP; 167 } 168 } else if (fqn.equals(FQCN_DATE_PICKER) || fqn.equals(FQCN_TIME_PICKER)) { 169 IAndroidTarget renderingTarget = mPalette.getEditor().getRenderingTarget(); 170 // In Honeycomb, these widgets only render properly in the Holo themes. 171 int apiLevel = renderingTarget.getVersion().getApiLevel(); 172 if (apiLevel == 11) { 173 String themeName = mPalette.getCurrentTheme(); 174 if (themeName == null || !themeName.startsWith("Theme.Holo")) { //$NON-NLS-1$ 175 // Note - it's possible that the the theme is some other theme 176 // such as a user theme which inherits from Theme.Holo and that 177 // the render -would- have worked, but it's harder to detect that 178 // scenario, so we err on the side of caution and just show an 179 // icon + name for the time widgets. 180 renderMode = RenderMode.SKIP; 181 } 182 } else if (apiLevel >= 12) { 183 // Currently broken, even for Holo. 184 renderMode = RenderMode.SKIP; 185 } // apiLevel <= 10 is fine 186 } 187 188 if (renderMode == RenderMode.ALONE) { 189 elements.add(Collections.singletonList(element)); 190 } else if (renderMode == RenderMode.NORMAL) { 191 shared.add(element); 192 } else { 193 assert renderMode == RenderMode.SKIP; 194 } 195 } 196 } 197 198 return elements; 199 } 200 201 /** 202 * Renders ALL the widgets and then extracts image data for each view and saves it on 203 * disk 204 */ 205 private boolean render() { 206 File imageDir = getImageDir(true); 207 208 GraphicalEditorPart editor = mPalette.getEditor(); 209 LayoutEditor layoutEditor = editor.getLayoutEditor(); 210 LayoutLibrary layoutLibrary = editor.getLayoutLibrary(); 211 Integer overrideBgColor = null; 212 if (layoutLibrary != null) { 213 if (layoutLibrary.supports(Capability.CUSTOM_BACKGROUND_COLOR)) { 214 Pair<RGB, RGB> themeColors = getColorsFromTheme(); 215 RGB bg = themeColors.getFirst(); 216 RGB fg = themeColors.getSecond(); 217 if (bg != null) { 218 storeBackground(imageDir, bg, fg); 219 overrideBgColor = Integer.valueOf(ImageUtils.rgbToInt(bg, 0xFF)); 220 } 221 } 222 } 223 224 ViewMetadataRepository repository = ViewMetadataRepository.get(); 225 Document document = repository.getRenderingConfigDoc(); 226 227 if (document == null) { 228 return false; 229 } 230 231 // Construct UI model from XML 232 AndroidTargetData data = layoutEditor.getTargetData(); 233 DocumentDescriptor documentDescriptor; 234 if (data == null) { 235 documentDescriptor = new DocumentDescriptor("temp", null/*children*/);//$NON-NLS-1$ 236 } else { 237 documentDescriptor = data.getLayoutDescriptors().getDescriptor(); 238 } 239 UiDocumentNode model = (UiDocumentNode) documentDescriptor.createUiNode(); 240 model.setEditor(layoutEditor); 241 model.setUnknownDescriptorProvider(editor.getModel().getUnknownDescriptorProvider()); 242 243 Element documentElement = document.getDocumentElement(); 244 List<List<Element>> elements = partitionRenderElements(document); 245 for (List<Element> elementGroup : elements) { 246 // Replace the document elements with the current element group 247 while (documentElement.getFirstChild() != null) { 248 documentElement.removeChild(documentElement.getFirstChild()); 249 } 250 for (Element element : elementGroup) { 251 documentElement.appendChild(element); 252 } 253 254 model.loadFromXmlNode(document); 255 256 RenderSession session = null; 257 NodeList childNodes = documentElement.getChildNodes(); 258 try { 259 // Important to get these sizes large enough for clients that don't support 260 // RenderMode.FULL_EXPAND such as 1.6 261 int width = 200; 262 int height = childNodes.getLength() == 1 ? 400 : 1600; 263 264 session = RenderService.create(editor) 265 .setModel(model) 266 .setSize(width, height) 267 .setRenderingMode(RenderingMode.FULL_EXPAND) 268 .setLog(new RenderLogger("palette")) 269 .setOverrideBgColor(overrideBgColor) 270 .setDecorations(false) 271 .createRenderSession(); 272 } catch (Throwable t) { 273 // If there are internal errors previewing the components just revert to plain 274 // icons and labels 275 continue; 276 } 277 278 if (session != null) { 279 if (session.getResult().isSuccess()) { 280 BufferedImage image = session.getImage(); 281 if (image != null && image.getWidth() > 0 && image.getHeight() > 0) { 282 283 // Fallback for older platforms where we couldn't do background rendering 284 // at the beginning of this method 285 if (mBackground == null) { 286 Pair<RGB, RGB> themeColors = getColorsFromTheme(); 287 RGB bg = themeColors.getFirst(); 288 RGB fg = themeColors.getSecond(); 289 290 if (bg == null) { 291 // Just use a pixel from the rendering instead. 292 int p = image.getRGB(image.getWidth() - 1, image.getHeight() - 1); 293 // However, in this case we don't trust the foreground color 294 // even if one was found in the themes; pick one that is guaranteed 295 // to contrast with the background 296 bg = ImageUtils.intToRgb(p); 297 if (ImageUtils.getBrightness(ImageUtils.rgbToInt(bg, 255)) < 128) { 298 fg = new RGB(255, 255, 255); 299 } else { 300 fg = new RGB(0, 0, 0); 301 } 302 } 303 storeBackground(imageDir, bg, fg); 304 assert mBackground != null; 305 } 306 307 List<ViewInfo> viewInfoList = session.getRootViews(); 308 if (viewInfoList != null && viewInfoList.size() > 0) { 309 // We don't render previews under a <merge> so there should 310 // only be one root. 311 ViewInfo firstRoot = viewInfoList.get(0); 312 int parentX = firstRoot.getLeft(); 313 int parentY = firstRoot.getTop(); 314 List<ViewInfo> infos = firstRoot.getChildren(); 315 for (ViewInfo info : infos) { 316 Object cookie = info.getCookie(); 317 if (!(cookie instanceof UiElementNode)) { 318 continue; 319 } 320 UiElementNode node = (UiElementNode) cookie; 321 String fileName = getFileName(node); 322 File file = new File(imageDir, fileName); 323 if (file.exists()) { 324 // On Windows, perhaps we need to rename instead? 325 file.delete(); 326 } 327 int x1 = parentX + info.getLeft(); 328 int y1 = parentY + info.getTop(); 329 int x2 = parentX + info.getRight(); 330 int y2 = parentY + info.getBottom(); 331 if (x1 != x2 && y1 != y2) { 332 savePreview(file, image, x1, y1, x2, y2); 333 } 334 } 335 } 336 } 337 } else { 338 StringBuilder sb = new StringBuilder(); 339 for (int i = 0, n = childNodes.getLength(); i < n; i++) { 340 Node node = childNodes.item(i); 341 if (node instanceof Element) { 342 Element e = (Element) node; 343 String fqn = repository.getFullClassName(e); 344 fqn = fqn.substring(fqn.lastIndexOf('.') + 1); 345 if (sb.length() > 0) { 346 sb.append(", "); //$NON-NLS-1$ 347 } 348 sb.append(fqn); 349 } 350 } 351 AdtPlugin.log(IStatus.WARNING, "Failed to render set of icons for %1$s", 352 sb.toString()); 353 354 if (session.getResult().getException() != null) { 355 AdtPlugin.log(session.getResult().getException(), 356 session.getResult().getErrorMessage()); 357 } else if (session.getResult().getErrorMessage() != null) { 358 AdtPlugin.log(IStatus.WARNING, session.getResult().getErrorMessage()); 359 } 360 } 361 362 session.dispose(); 363 } 364 } 365 366 return true; 367 } 368 369 /** 370 * Look up the background and foreground colors from the theme. May not find either 371 * the background or foreground or both, but will always return a pair of possibly 372 * null colors. 373 * 374 * @return a pair of possibly null color descriptions 375 */ 376 private Pair<RGB, RGB> getColorsFromTheme() { 377 RGB background = null; 378 RGB foreground = null; 379 380 ResourceResolver resources = mPalette.getEditor().getResourceResolver(); 381 StyleResourceValue theme = resources.getCurrentTheme(); 382 if (theme != null) { 383 background = resolveThemeColor(resources, "windowBackground"); //$NON-NLS-1$ 384 if (background == null) { 385 background = renderDrawableResource("windowBackground"); //$NON-NLS-1$ 386 // This causes some harm with some themes: We'll find a color, say black, 387 // that isn't actually rendered in the theme. Better to use null here, 388 // which will cause the caller to pick a pixel from the observed background 389 // instead. 390 //if (background == null) { 391 // background = resolveThemeColor(resources, "colorBackground"); //$NON-NLS-1$ 392 //} 393 } 394 foreground = resolveThemeColor(resources, "textColorPrimary"); //$NON-NLS-1$ 395 } 396 397 // Ensure that the foreground color is suitably distinct from the background color 398 if (background != null) { 399 int bgRgb = ImageUtils.rgbToInt(background, 0xFF); 400 int backgroundBrightness = ImageUtils.getBrightness(bgRgb); 401 if (foreground == null) { 402 if (backgroundBrightness < 128) { 403 foreground = new RGB(255, 255, 255); 404 } else { 405 foreground = new RGB(0, 0, 0); 406 } 407 } else { 408 int fgRgb = ImageUtils.rgbToInt(foreground, 0xFF); 409 int foregroundBrightness = ImageUtils.getBrightness(fgRgb); 410 if (Math.abs(backgroundBrightness - foregroundBrightness) < 64) { 411 if (backgroundBrightness < 128) { 412 foreground = new RGB(255, 255, 255); 413 } else { 414 foreground = new RGB(0, 0, 0); 415 } 416 } 417 } 418 } 419 420 return Pair.of(background, foreground); 421 } 422 423 /** 424 * Renders the given resource which should refer to a drawable and returns a 425 * representative color value for the drawable (such as the color in the center) 426 * 427 * @param themeItemName the item in the theme to be looked up and rendered 428 * @return a color representing a typical color in the drawable 429 */ 430 private RGB renderDrawableResource(String themeItemName) { 431 GraphicalEditorPart editor = mPalette.getEditor(); 432 ResourceResolver resources = editor.getResourceResolver(); 433 ResourceValue resourceValue = resources.findItemInTheme(themeItemName); 434 BufferedImage image = RenderService.create(editor) 435 .setSize(100, 100) 436 .renderDrawable(resourceValue); 437 if (image != null) { 438 // Use the middle pixel as the color since that works better for gradients; 439 // solid colors work too. 440 int rgb = image.getRGB(image.getWidth() / 2, image.getHeight() / 2); 441 return ImageUtils.intToRgb(rgb); 442 } 443 444 return null; 445 } 446 447 private static RGB resolveThemeColor(ResourceResolver resources, String resourceName) { 448 ResourceValue textColor = resources.findItemInTheme(resourceName); 449 return ResourceHelper.resolveColor(resources, textColor); 450 } 451 452 private String getFileName(ElementDescriptor descriptor) { 453 if (descriptor instanceof PaletteMetadataDescriptor) { 454 PaletteMetadataDescriptor pmd = (PaletteMetadataDescriptor) descriptor; 455 StringBuilder sb = new StringBuilder(); 456 String name = pmd.getUiName(); 457 // Strip out whitespace, parentheses, etc. 458 for (int i = 0, n = name.length(); i < n; i++) { 459 char c = name.charAt(i); 460 if (Character.isLetter(c)) { 461 sb.append(c); 462 } 463 } 464 return sb.toString() + DOT_PNG; 465 } 466 return descriptor.getUiName() + DOT_PNG; 467 } 468 469 private String getFileName(UiElementNode node) { 470 ViewMetadataRepository repository = ViewMetadataRepository.get(); 471 String fqn = repository.getFullClassName((Element) node.getXmlNode()); 472 return fqn.substring(fqn.lastIndexOf('.') + 1) + DOT_PNG; 473 } 474 475 /** 476 * Cleans up a name by removing punctuation and whitespace etc to make 477 * it a better filename 478 * @param name 479 * @return 480 */ 481 private static String cleanup(String name) { 482 // Extract just the characters (no whitespace, parentheses, punctuation etc) 483 // to ensure that the filename is pretty portable 484 StringBuilder sb = new StringBuilder(name.length()); 485 for (int i = 0; i < name.length(); i++) { 486 char c = name.charAt(i); 487 if (Character.isJavaIdentifierPart(c)) { 488 sb.append(Character.toLowerCase(c)); 489 } 490 } 491 492 return sb.toString(); 493 } 494 495 /** Returns the location of a directory containing image previews (which may not exist) */ 496 private File getImageDir(boolean create) { 497 if (mImageDir == null) { 498 // Location for plugin-related state data 499 IPath pluginState = AdtPlugin.getDefault().getStateLocation(); 500 501 // We have multiple directories - one for each combination of SDK, theme and device 502 // (and later, possibly other qualifiers). 503 // These are created -lazily-. 504 String targetName = mPalette.getCurrentTarget().hashString(); 505 String androidTargetNamePrefix = "android-"; 506 String themeNamePrefix = "Theme."; 507 if (targetName.startsWith(androidTargetNamePrefix)) { 508 targetName = targetName.substring(androidTargetNamePrefix.length()); 509 } 510 String themeName = mPalette.getCurrentTheme(); 511 if (themeName == null) { 512 themeName = "Theme"; //$NON-NLS-1$ 513 } 514 if (themeName.startsWith(themeNamePrefix)) { 515 themeName = themeName.substring(themeNamePrefix.length()); 516 } 517 String dirName = String.format("palette-preview-r11d-%s-%s-%s", cleanup(targetName), 518 cleanup(themeName), cleanup(mPalette.getCurrentDevice())); 519 IPath dirPath = pluginState.append(dirName); 520 521 mImageDir = new File(dirPath.toOSString()); 522 } 523 524 if (create && !mImageDir.exists()) { 525 mImageDir.mkdirs(); 526 } 527 528 return mImageDir; 529 } 530 531 private void savePreview(File output, BufferedImage image, 532 int left, int top, int right, int bottom) { 533 try { 534 BufferedImage im = ImageUtils.subImage(image, left, top, right, bottom); 535 ImageIO.write(im, "PNG", output); //$NON-NLS-1$ 536 } catch (IOException e) { 537 AdtPlugin.log(e, "Failed writing palette file"); 538 } 539 } 540 541 private void storeBackground(File imageDir, RGB bg, RGB fg) { 542 mBackground = bg; 543 mForeground = fg; 544 File file = new File(imageDir, PREVIEW_INFO_FILE); 545 String colors = String.format( 546 "background=#%02x%02x%02x\nforeground=#%02x%02x%02x\n", //$NON-NLS-1$ 547 bg.red, bg.green, bg.blue, 548 fg.red, fg.green, fg.blue); 549 AdtPlugin.writeFile(file, colors); 550 } 551 552 public RGB getBackgroundColor() { 553 if (mBackground == null) { 554 initColors(); 555 } 556 557 return mBackground; 558 } 559 560 public RGB getForegroundColor() { 561 if (mForeground == null) { 562 initColors(); 563 } 564 565 return mForeground; 566 } 567 568 public void initColors() { 569 try { 570 // Already initialized? Foreground can be null which would call 571 // initColors again and again, but background is never null after 572 // initialization so we use it as the have-initialized flag. 573 if (mBackground != null) { 574 return; 575 } 576 577 File imageDir = getImageDir(false); 578 if (!imageDir.exists()) { 579 render(); 580 581 // Initialized as part of the render 582 if (mBackground != null) { 583 return; 584 } 585 } 586 587 File file = new File(imageDir, PREVIEW_INFO_FILE); 588 if (file.exists()) { 589 Properties properties = new Properties(); 590 InputStream is = null; 591 try { 592 is = new BufferedInputStream(new FileInputStream(file)); 593 properties.load(is); 594 } catch (IOException e) { 595 AdtPlugin.log(e, "Can't read preview properties"); 596 } finally { 597 if (is != null) { 598 try { 599 is.close(); 600 } catch (IOException e) { 601 // Nothing useful can be done. 602 } 603 } 604 } 605 606 String colorString = (String) properties.get("background"); //$NON-NLS-1$ 607 if (colorString != null) { 608 int rgb = ImageUtils.getColor(colorString.trim()); 609 mBackground = ImageUtils.intToRgb(rgb); 610 } 611 colorString = (String) properties.get("foreground"); //$NON-NLS-1$ 612 if (colorString != null) { 613 int rgb = ImageUtils.getColor(colorString.trim()); 614 mForeground = ImageUtils.intToRgb(rgb); 615 } 616 } 617 618 if (mBackground == null) { 619 mBackground = new RGB(0, 0, 0); 620 } 621 // mForeground is allowed to be null. 622 } catch (Throwable t) { 623 AdtPlugin.log(t, "Cannot initialize preview color settings"); 624 } 625 } 626 } 627