/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tools.idea.wizard; import com.android.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.base.Functions; import com.google.common.base.Objects; import com.google.common.base.Optional; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.util.text.StringUtil; import com.intellij.util.ui.GeometryUtil; import com.intellij.util.ui.GraphicsUtil; import com.intellij.util.ui.UIUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.accessibility.*; import javax.swing.*; import javax.swing.border.Border; import javax.swing.event.*; import java.awt.*; import java.awt.event.*; import java.awt.font.LineMetrics; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.util.Map; import java.util.NoSuchElementException; import java.util.concurrent.ExecutionException; /** * A gallery widget for displaying a collection of images. * <p/> * This widget obtains its model from {@link javax.swing.ListModel} and * relies on two functions to obtain image and lable for model object. * It does not support notions of "renderer" or "editor" */ public class ASGallery<E> extends JComponent implements Accessible, Scrollable { /** * Default insets around the cell contents. */ private static final Insets DEFAULT_CELL_MARGIN = new Insets(1, 1, 1, 1); /** * Insets around cell content (image and title). */ @NotNull private Insets myCellMargin = DEFAULT_CELL_MARGIN; /** * Timeout in ms when incremental search is reset */ private static final int INCSEARCH_TIMEOUT_MS = 500; // ms /** * Listeners for events other then property event */ private final EventListenerList myListeners = new EventListenerList(); /** * Listens to changes in the model data */ private final ListDataListener myListDataListener = new InternalListDataListener(); /** * Size of the image. Currently all images will be scaled to this size, this * may change as we get more requirements. */ @NotNull private Dimension myThumbnailSize; /** * Index of the selected item or -1 if none */ private int mySelectedIndex = -1; /** * Data shown in this component */ private ListModel myModel; /** * Filter string for the incremental search */ private String myFilterString = ""; /** * Timestamp of the last keypress used for incremental search. */ private long myPreviousKeypressTimestamp = 0; /** * Caches item images, is reset if different image provider is supplied. */ @NotNull private LoadingCache<E, Optional<Image>> myImagesCache; /** * Obtains string label for the model object. */ @NotNull private Function<? super E, String> myLabelProvider; public ASGallery() { this(new DefaultListModel(), Functions.<Image>constant(null), Functions.toStringFunction(), new Dimension(0, 0)); } public ASGallery(@NotNull ListModel model, @NotNull Function<? super E, Image> imageProvider, @NotNull Function<? super E, String> labelProvider, @NotNull Dimension thumbnailSize) { Font listFont = UIUtil.getListFont(); if (listFont != null) { setFont(listFont); } setThumbnailSize(thumbnailSize); setImageProvider(imageProvider); setLabelProvider(labelProvider); setModel(model); setOpaque(true); setFocusable(true); addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { requestFocusInWindow(); int cell = getCellAt(e.getPoint()); if (cell >= 0) { setSelectedIndex(cell); } } }); addFocusListener(new FocusListener() { @Override public void focusGained(FocusEvent e) { reveal(); updateFocusRectangle(); } @Override public void focusLost(FocusEvent e) { updateFocusRectangle(); } }); addKeyListener(new KeyAdapter() { @Override public void keyTyped(KeyEvent e) { char keyChar = e.getKeyChar(); if (keyChar != KeyEvent.CHAR_UNDEFINED) { incrementalSearch(keyChar); } } }); setBackground(UIUtil.getListBackground()); InputMap inputMap = getInputMap(JComponent.WHEN_FOCUSED); ActionMap actionMap = getActionMap(); ImmutableMap<Integer, Action> keysToActions = ImmutableMap.<Integer, Action>builder(). put(KeyEvent.VK_DOWN, new MoveSelectionAction(1, 0)). put(KeyEvent.VK_UP, new MoveSelectionAction(-1, 0)). put(KeyEvent.VK_LEFT, new MoveSelectionAction(0, -1)). put(KeyEvent.VK_RIGHT, new MoveSelectionAction(0, 1)). put(KeyEvent.VK_HOME, new JumpSelection() { @Override public int getIndex() { return 0; } }). put(KeyEvent.VK_END, new JumpSelection() { @Override public int getIndex() { return myModel.getSize() - 1; } }). build(); for (Map.Entry<Integer, Action> entry : keysToActions.entrySet()) { String key = "selection_move_" + entry.getKey(); inputMap.put(KeyStroke.getKeyStroke(entry.getKey(), 0), key); actionMap.put(key, entry.getValue()); } } @Override public Dimension getMinimumSize() { Dimension size = new Dimension(computeCellSize()); Insets insets = getInsets(); size.setSize(size.getWidth() + insets.left + insets.right, size.getHeight() + insets.top + insets.bottom); return computeCellSize(); } private static int intDivideRoundUp(int divident, int divisor) { return (divident + divisor - 1) / divisor; } private static int getElementIndex(@NotNull ListModel model, @Nullable Object element) { if (element == null) { return -1; } for (int i = 0; i < model.getSize(); i++) { Object modelElement = model.getElementAt(i); if (Objects.equal(element, modelElement)) { return i; } } return -1; } public void setLabelProvider(@NotNull Function<? super E, String> labelProvider) { myLabelProvider = labelProvider; repaint(getVisibleRect()); } public void setThumbnailSize(@NotNull Dimension thumbnailSize) { if (!Objects.equal(thumbnailSize, myThumbnailSize)) { myThumbnailSize = thumbnailSize; invalidate(); repaint(getVisibleRect()); } } /** * Set the function that obtains the image for the item. * <p/> * Values are cached. We may need to provide a way to force value update if * it is needed at a later time. * (Implementation detail) Cache uses identity (==) comparison and does not * use {@link Object#equals(Object)}. Please do not rely on this behaviour * as it may change without prior notice. */ public void setImageProvider(@NotNull Function<? super E, Image> imageProvider) { CacheLoader<? super E, Optional<Image>> cacheLoader = CacheLoader.from(ToOptionalFunction.wrap(imageProvider)); myImagesCache = CacheBuilder.newBuilder().weakKeys().build(cacheLoader); repaint(getVisibleRect()); } private void incrementalSearch(char keyChar) { final long timestamp = System.currentTimeMillis(); if (timestamp - myPreviousKeypressTimestamp > INCSEARCH_TIMEOUT_MS) { myFilterString = String.valueOf(keyChar); } else { myFilterString += keyChar; } final int ind = findMatchingItem(); myPreviousKeypressTimestamp = timestamp; if (ind < 0) { boolean resumedSearch = myFilterString.length() > 1; myFilterString = ""; if (resumedSearch) { incrementalSearch(keyChar); } } else { setSelectedIndex(ind); } } private int findMatchingItem() { int itemCount = myModel.getSize(); int startingIndex = Math.max(0, mySelectedIndex); // Ideal match starts with the search string. Otherwise we try to match // words (e.g. "maps" should match Google maps template) int secondBest = -1; final String normalizedFilterString = StringUtil.toLowerCase(myFilterString); for (int i = startingIndex; i < itemCount + startingIndex; i++) { // We only should to wrap search if there's no matches "under" the cursor final int index = i % itemCount; String title = getLabel(index); if (!StringUtil.isEmpty(title)) { String normalizedTitle = StringUtil.toLowerCase(title); if (normalizedTitle.startsWith(normalizedFilterString)) { return index; } else if (secondBest < 0 && normalizedTitle.contains(" " + normalizedFilterString)) { secondBest = index; } } } return secondBest; } @Nullable private String getLabel(int index) { Object element = myModel.getElementAt(index); if (element == null) { return null; } else { //noinspection unchecked return myLabelProvider.apply((E)element); } } @VisibleForTesting protected int getCellAt(@NotNull Point point) { Insets borderInsets = getInsets(); int columnCount = getColumnCount(); Dimension cellDimensions = computeCellSize(); int galleryWidth = getClientWidth(borderInsets); int offsetX = point.x - borderInsets.left; int offsetY = point.y - borderInsets.top; if (offsetX >= galleryWidth || offsetX < 0) { return -1; } int column = 0; // We may have columns of (slightly) varied width due to rounding errors... while (getColumnOffset(column + 1, columnCount, galleryWidth) <= offsetX) { column++; } int row = offsetY / cellDimensions.height; if (row < 0 || row > myModel.getSize() / columnCount) { return -1; } int selection = column + row * columnCount; return selection >= 0 && selection < myModel.getSize() ? selection : -1; } private int getClientWidth(Insets borderInsets) { return getWidth() - borderInsets.left - borderInsets.right; } private int getNewSelectionIndex(int vdirection, int hdirection) { if (mySelectedIndex < 0) { return 0; } int columnCount = getColumnCount(); int column = mySelectedIndex % columnCount + hdirection; int row = mySelectedIndex / columnCount + vdirection; if (column >= 0 && column < columnCount) { int newSelection = column + row * columnCount; if (newSelection < 0) { return 0; } else { int itemCount = myModel.getSize(); if (newSelection >= itemCount) { return itemCount - 1; } else { return newSelection; } } } else { return mySelectedIndex; } } private void updateFocusRectangle() { if (mySelectedIndex >= 0) { repaint(getVisibleRect()); } } @NotNull public Insets getCellMargin() { return myCellMargin; } /** * Set cell margin value. */ public void setCellMargin(@Nullable Insets cellMargin) { cellMargin = cellMargin == null ? DEFAULT_CELL_MARGIN : cellMargin; if (Objects.equal(cellMargin, myCellMargin)) { Insets oldInsets = myCellMargin; myCellMargin = cellMargin; firePropertyChange("cellMargin", oldInsets, cellMargin); } } @Nullable public E getSelectedElement() { if (mySelectedIndex < 0) { return null; } //noinspection unchecked return (E)myModel.getElementAt(mySelectedIndex); } public void setSelectedElement(@Nullable E element) { final int index; if (element == null) { index = -1; } else { index = getElementIndex(getModel(), element); if (index < 0) { throw new NoSuchElementException(element.toString()); } } setSelectedIndex(index); } public int getSelectedIndex() { return mySelectedIndex; } public void setSelectedIndex(int selectedIndex) { setSelectedIndex(selectedIndex, true); } private void setSelectedIndex(int selectedIndex, boolean notifyListeners) { assert selectedIndex < myModel.getSize() && selectedIndex >= -1; if (selectedIndex != mySelectedIndex) { mySelectedIndex = selectedIndex; repaint(getVisibleRect()); revealCell(mySelectedIndex); if (notifyListeners) { fireSelectionChanged(mySelectedIndex); } } } private void fireSelectionChanged(int newSelection) { boolean isSelectionListener = false; ListSelectionEvent event = new ListSelectionEvent(this, newSelection, newSelection, false); for (Object object : myListeners.getListenerList()) { if (isSelectionListener) { ((ListSelectionListener)object).valueChanged(event); isSelectionListener = false; } else { isSelectionListener = object == ListSelectionListener.class; } } } /** * @return data model */ @NotNull public ListModel getModel() { return myModel; } public void setModel(@NotNull ListModel model) { if (!Objects.equal(myModel, model)) { final Object element; //noinspection ConstantConditions if (myModel != null) { myModel.removeListDataListener(myListDataListener); element = mySelectedIndex < 0 ? null : myModel.getElementAt(mySelectedIndex); } else { element = null; } myModel = model; myModel.addListDataListener(myListDataListener); invalidate(); setSelectedIndex(getElementIndex(myModel, element)); } } @Override public Dimension getPreferredSize() { Dimension preferredSize = super.getPreferredSize(); int itemCount = myModel == null ? 0 : myModel.getSize(); if (isPreferredSizeSet() || itemCount == 0) { return preferredSize; } Insets insets = getInsets(); int insetsWidth = insets.left + insets.right; int insetsHeight = insets.top + insets.bottom; Dimension cellSize = computeCellSize(); final int width = getWidth(); if (width == 0) { return new Dimension(cellSize.width + insetsWidth, cellSize.height + insetsHeight); } else { // Avoid horizontal scroll int rows = intDivideRoundUp(itemCount, getColumnCount()); int height = rows * cellSize.height + insetsHeight; return new Dimension(Math.max(width, cellSize.width + insetsWidth), height); } } @VisibleForTesting protected int getColumnCount() { Dimension cellSize = computeCellSize(); int width = getClientWidth(getInsets()); int columnCount = Math.max(width / cellSize.width, 1); if (myModel != null) { int entries = myModel.getSize(); // If one row, spread out the entries - but don't increase the entry width more then 2x, doesn't look right then if (columnCount > entries && columnCount < entries * 2) { return entries; } } return columnCount; } @VisibleForTesting protected Dimension computeCellSize() { Dimension imageSize = myThumbnailSize; int width = imageSize.width + myCellMargin.left + myCellMargin.right; int textHeight = getFont().getSize(); int height = imageSize.height + myCellMargin.top + myCellMargin.bottom + 2 * textHeight; return new Dimension(width, height); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Rectangle clipRectangle = g.getClipBounds(); if (isOpaque()) { g.setColor(getBackground()); g.fillRect(clipRectangle.x, clipRectangle.y, clipRectangle.width, clipRectangle.height); } Dimension cellBounds = computeCellSize(); int firstColumn = clipRectangle.x / cellBounds.width; int lastColumn = (clipRectangle.x + clipRectangle.width) / cellBounds.width; int firstRow = clipRectangle.y / cellBounds.height; int lastRow = intDivideRoundUp(clipRectangle.y + clipRectangle.height, cellBounds.height); Insets borderInsets = getInsets(); int componentWidth = getClientWidth(borderInsets); int columns = getColumnCount(); for (int row = firstRow; row <= lastRow; row++) { for (int column = firstColumn; column <= lastColumn; column++) { int cell = row * columns + column; if (cell >= myModel.getSize()) { break; } // Intermediate values like componentWidth/columns are not cached // as we are doing integer math here and rounding errors might // accumulate and cause "holes" in control we are painting. final int cellX = getColumnOffset(column, columns, componentWidth); final int width = getColumnOffset(column + 1, columns, componentWidth) - cellX; int cellY = row * cellBounds.height + borderInsets.top; int cellHeight = cellBounds.height - 1; Rectangle bounds = new Rectangle(cellX + borderInsets.left, cellY, width, cellHeight); paintCell(g, cell, bounds); } } } private void paintCell(Graphics g, int cell, Rectangle cellBounds) { String label = getLabel(cell); Image thumbnail = getImage(cell); drawSelection(g, cell, cellBounds, !StringUtil.isEmpty(label) && thumbnail != null); final int thumbnailHeight; if (thumbnail != null) { Dimension thumbnailSize = myThumbnailSize; int imageX = cellBounds.x + (cellBounds.width - thumbnailSize.width) / 2; int imageY = cellBounds.y + myCellMargin.top; g.drawImage(thumbnail, imageX, imageY, thumbnailSize.width, thumbnailSize.height, null); thumbnailHeight = thumbnailSize.height; } else { thumbnailHeight = 0; } paintLabel(g, cell, cellBounds, label, thumbnailHeight); } private void paintLabel(Graphics g, int cell, Rectangle cellBounds, @Nullable String label, int thumbnailHeight) { if (!StringUtil.isEmpty(label)) { final Color fg; if (hasFocus() && cell == mySelectedIndex && (getImage(cell) != null || UIUtil.isUnderDarcula())) { fg = UIUtil.getTreeSelectionForeground(); } else { fg = UIUtil.getTreeForeground(); } GraphicsUtil.setupAntialiasing(g); g.setColor(fg); FontMetrics fontMetrics = g.getFontMetrics(); LineMetrics metrics = fontMetrics.getLineMetrics(label, g); int width = fontMetrics.stringWidth(label); int textBoxTop = myCellMargin.top + thumbnailHeight; int cellBottom = cellBounds.height - myCellMargin.bottom; int textY = cellBounds.y + (cellBottom + textBoxTop + (int)(metrics.getHeight() - metrics.getDescent())) / 2 ; int textX = (cellBounds.width - myCellMargin.left - myCellMargin.right - width) / 2 + cellBounds.x + myCellMargin.left; g.drawString(label, textX, textY); } } private void drawSelection(Graphics g, int cell, Rectangle cellBounds, boolean paintLabelBackground) { if (cell == mySelectedIndex) { Color currentColor = g.getColor(); Color bg = UIUtil.getTreeSelectionBackground(hasFocus()); g.setColor(bg); g.drawRect(cellBounds.x, cellBounds.y, cellBounds.width - 1, cellBounds.height - 1); if (paintLabelBackground) { int textBoxTop = myThumbnailSize.height + myCellMargin.top; g.fillRect(cellBounds.x, cellBounds.y + textBoxTop, cellBounds.width - 1, cellBounds.height - textBoxTop); } if (hasFocus()) { Border border = UIUtil.getTableFocusCellHighlightBorder(); border.paintBorder(this, g, cellBounds.x, cellBounds.y, cellBounds.width, cellBounds.height); } g.setColor(currentColor); } } @Nullable private Image getImage(int cell) { Object elementAt = myModel.getElementAt(cell); if (elementAt == null) { return null; } else { try { @SuppressWarnings("unchecked") Optional<Image> image = myImagesCache.get((E)elementAt); return image.orNull(); } catch (ExecutionException e) { Logger.getInstance(getClass()).error(e); return null; } } } @Override public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); } @Override public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) { return 10; } @Override public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) { return computeCellSize().height; } @Override public boolean getScrollableTracksViewportWidth() { return true; } @Override public boolean getScrollableTracksViewportHeight() { return false; } public void addListSelectionListener(ListSelectionListener listener) { myListeners.add(ListSelectionListener.class, listener); } public void removeListSelectionListener(ListSelectionListener listener) { myListeners.remove(ListSelectionListener.class, listener); } private void reveal() { int selectedIndex = getSelectedIndex(); if (selectedIndex > 0) { revealCell(selectedIndex); } } private void revealCell(int selectedIndex) { int columnCount = getColumnCount(); Insets borderInsets = getInsets(); int width = getClientWidth(borderInsets); int height = computeCellSize().height; int column = selectedIndex % columnCount; int x = getColumnOffset(column, columnCount, width) + borderInsets.left; int y = (selectedIndex / columnCount) * height + borderInsets.top; scrollRectToVisible(new Rectangle(x, y, width, height)); } @VisibleForTesting protected int getColumnOffset(int column, int columnCount, int galleryWidth) { if (columnCount <= myModel.getSize()) { return column * galleryWidth / columnCount; } else { return column * computeCellSize().width; } } @Override public AccessibleContext getAccessibleContext() { if (accessibleContext == null) { accessibleContext = new AccessibleASGallery(); } return accessibleContext; } /** * Guava containers do not like <code>null</code> values. This function * wraps such values into {@link com.google.common.base.Optional}. */ private static final class ToOptionalFunction<P, R> implements Function<P, Optional<R>> { private final Function<P, R> myFunction; public ToOptionalFunction(Function<P, R> function) { myFunction = function; } public static <P, R> Function<P, Optional<R>> wrap(Function<P, R> function) { return new ToOptionalFunction<P, R>(function); } @Override public Optional<R> apply(P input) { R result = myFunction.apply(input); return Optional.fromNullable(result); } } private class MoveSelectionAction extends AbstractAction { private final int myVdirection; private final int myHdirection; public MoveSelectionAction(int vdirection, int hdirection) { myVdirection = vdirection; myHdirection = hdirection; } @Override public boolean isEnabled() { return getNewSelectionIndex(myVdirection, myHdirection) != mySelectedIndex; } @Override public void actionPerformed(ActionEvent e) { setSelectedIndex(getNewSelectionIndex(myVdirection, myHdirection)); } } private abstract class JumpSelection extends AbstractAction { public abstract int getIndex(); @Override public boolean isEnabled() { return getIndex() >= 0; } @Override public void actionPerformed(ActionEvent e) { setSelectedIndex(getIndex()); } } private class InternalListDataListener implements ListDataListener { @Override public void intervalAdded(ListDataEvent e) { if (e.getIndex0() <= mySelectedIndex) { final int newSelection = mySelectedIndex + e.getIndex1() - e.getIndex0() - 1; setSelectedIndex(newSelection, false); } invalidate(); } @Override public void intervalRemoved(ListDataEvent e) { int firstRemoved = e.getIndex0(); if (firstRemoved <= mySelectedIndex) { final int lastRemoved = e.getIndex1(); // Retain selection if this element was not deleted. // Move selection down if the element was deleted if there are elements after selected // Move selection up otherwise // Remove selection if the list is empty final int index = mySelectedIndex - (lastRemoved - firstRemoved + 1); final int newSelectionIndex = Math.min(Math.max(index, e.getIndex0()), myModel.getSize() - 1); // Notify if selected element was deleted setSelectedIndex(newSelectionIndex, mySelectedIndex <= lastRemoved); } invalidate(); } @Override public void contentsChanged(ListDataEvent e) { invalidate(); } } private final class AccessibleASGallery extends AccessibleJComponent implements PropertyChangeListener, ListSelectionListener { private Map<Integer, Accessible> children = Maps.newHashMap(); public AccessibleASGallery() { setAccessibleName(ASGallery.this.getName()); ASGallery.this.addPropertyChangeListener(this); ASGallery.this.addListSelectionListener(this); } @Override public void propertyChange(PropertyChangeEvent evt) { if ("name".equals(evt.getPropertyName())) { setAccessibleName((String)evt.getNewValue()); } else if ("model".equals(evt.getPropertyName())) { firePropertyChange(AccessibleContext.ACCESSIBLE_INVALIDATE_CHILDREN, null, ASGallery.this); } } @Override public int getAccessibleChildrenCount() { return getModel().getSize(); } @SuppressWarnings("unchecked") @Override public Accessible getAccessibleChild(int i) { if (!children.containsKey(i)) { children.put(i, new AccessibleCell(i)); } return children.get(i); } @Override public AccessibleRole getAccessibleRole() { return AccessibleRole.LIST; } @Override public void valueChanged(ListSelectionEvent e) { firePropertyChange(AccessibleContext.ACCESSIBLE_ACTIVE_DESCENDANT_PROPERTY, false, true); firePropertyChange(AccessibleContext.ACCESSIBLE_SELECTION_PROPERTY, false, true); } } private final class AccessibleCell extends AccessibleJComponent implements Accessible, AccessibleComponent, AccessibleAction { private final int myIndex; public AccessibleCell(int index) { myIndex = index; } @Override public AccessibleRole getAccessibleRole() { return AccessibleRole.LABEL; } @Override public AccessibleStateSet getAccessibleStateSet() { final AccessibleState[] state = {AccessibleState.SELECTABLE, AccessibleState.SINGLE_LINE, AccessibleState.ACTIVE}; return new AccessibleStateSet(state); } @Override public Accessible getAccessibleParent() { return ASGallery.this; } @Override public String getAccessibleName() { final String label = getLabel(myIndex); return StringUtil.isEmpty(label) ? "No Label" : label; } @Override public int getAccessibleIndexInParent() { return myIndex; } @Override public AccessibleContext getAccessibleContext() { return this; } @Override public int getAccessibleActionCount() { return 1; } @Override public String getAccessibleActionDescription(int i) { return AccessibleAction.CLICK; } @Override public boolean doAccessibleAction(int i) { setSelectedIndex(myIndex); return true; } } }