diff options
8 files changed, 2480 insertions, 5 deletions
diff --git a/android/src/com/android/tools/idea/gradle/invoker/messages/GradleBuildTreeViewPanel.java b/android/src/com/android/tools/idea/gradle/invoker/messages/GradleBuildTreeViewPanel.java index 397c31224f2..9edb1ea3586 100644 --- a/android/src/com/android/tools/idea/gradle/invoker/messages/GradleBuildTreeViewPanel.java +++ b/android/src/com/android/tools/idea/gradle/invoker/messages/GradleBuildTreeViewPanel.java @@ -16,6 +16,8 @@ package com.android.tools.idea.gradle.invoker.messages; import com.android.tools.idea.gradle.invoker.console.view.GradleConsoleToolWindowFactory; +import com.android.tools.idea.ui.MultilineColoredTreeCellRenderer; +import com.android.tools.idea.ui.WrapAwareTreeNodePartListener; import com.google.common.base.Joiner; import com.intellij.icons.AllIcons; import com.intellij.ide.errorTreeView.*; @@ -28,11 +30,11 @@ import com.intellij.openapi.project.DumbAwareAction; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Disposer; import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.changes.issueLinks.LinkMouseListenerBase; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowManager; import com.intellij.pom.Navigatable; -import com.intellij.ui.MultilineTreeCellRenderer; import com.intellij.ui.TreeSpeedSearch; import com.intellij.util.containers.Convertor; import com.intellij.util.ui.UIUtil; @@ -46,6 +48,7 @@ import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; import java.awt.*; +import java.awt.event.MouseListener; import java.util.Locale; import static com.google.common.base.Strings.nullToEmpty; @@ -83,9 +86,20 @@ public class GradleBuildTreeViewPanel extends NewErrorTreeViewPanel { assert parent instanceof JPanel; parent.remove(scrollPane); - scrollPane = MultilineTreeCellRenderer.installRenderer(myTree, new MessageTreeRenderer()); + scrollPane = MultilineColoredTreeCellRenderer.installRenderer(myTree, new MessageTreeRenderer()); parent.add(scrollPane, BorderLayout.CENTER); + MouseListener[] mouseListeners = myTree.getMouseListeners(); + if (mouseListeners != null) { + for (MouseListener mouseListener : mouseListeners) { + if (mouseListener instanceof LinkMouseListenerBase) { + // This listener is installed by default at NewErrorTreeViewPanel:153. + myTree.removeMouseListener(mouseListener); + } + } + } + new WrapAwareTreeNodePartListener(myTree.getCellRenderer()).installOn(myTree); + new TreeSpeedSearch(myTree, new Convertor<TreePath, String>() { @Override public String convert(TreePath treePath) { diff --git a/android/src/com/android/tools/idea/gradle/invoker/messages/MessageTreeRenderer.java b/android/src/com/android/tools/idea/gradle/invoker/messages/MessageTreeRenderer.java index f817cec18c4..b2dd307fd3c 100644 --- a/android/src/com/android/tools/idea/gradle/invoker/messages/MessageTreeRenderer.java +++ b/android/src/com/android/tools/idea/gradle/invoker/messages/MessageTreeRenderer.java @@ -15,9 +15,9 @@ */ package com.android.tools.idea.gradle.invoker.messages; +import com.android.tools.idea.ui.MultilineColoredTreeCellRenderer; import com.intellij.icons.AllIcons; import com.intellij.ide.errorTreeView.*; -import com.intellij.ui.MultilineTreeCellRenderer; import com.intellij.util.ArrayUtil; import com.intellij.util.ui.EmptyIcon; import org.jetbrains.annotations.NotNull; @@ -29,9 +29,9 @@ import javax.swing.tree.DefaultMutableTreeNode; /** * Renders elements in the "Messages" window. This renderer does not add the message type as a prefix (e.g. "Information:"); */ -class MessageTreeRenderer extends MultilineTreeCellRenderer { +class MessageTreeRenderer extends MultilineColoredTreeCellRenderer { @Override - protected void initComponent(JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { + protected void initComponent(@NotNull JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { ErrorTreeElement element = getElement(value); if (element != null) { diff --git a/android/src/com/android/tools/idea/ui/MultilineColoredTreeCellRenderer.java b/android/src/com/android/tools/idea/ui/MultilineColoredTreeCellRenderer.java new file mode 100644 index 00000000000..82aaee19db4 --- /dev/null +++ b/android/src/com/android/tools/idea/ui/MultilineColoredTreeCellRenderer.java @@ -0,0 +1,385 @@ +/* + * 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.ui; + +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.ui.JBColor; +import com.intellij.ui.LoadingNode; +import com.intellij.ui.SimpleTextAttributes; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.util.ui.EmptyIcon; +import com.intellij.util.ui.UIUtil; +import com.intellij.util.ui.tree.WideSelectionTreeUI; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.plaf.TreeUI; +import javax.swing.plaf.basic.BasicTreeUI; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreeCellRenderer; +import java.awt.*; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; + +public abstract class MultilineColoredTreeCellRenderer extends WrapAwareColoredComponent implements TreeCellRenderer { + + @NonNls protected static final String FONT_PROPERTY_NAME = "font"; + private static final Icon LOADING_NODE_ICON = new EmptyIcon(8, 16); + + @NotNull private final Insets myLabelInsets = new Insets(1, 2, 1, 2); + + @Nullable private String myPrefix; + + private int myPrefixWidth; + private int myMinHeight; + + /** + * Defines whether the tree is selected or not + */ + protected boolean mySelected; + /** + * Defines whether the tree has focus or not + */ + private boolean myFocused; + private boolean myFocusedCalculated; + + @Nullable protected JTree myTree; + + private boolean myOpaque = true; + + protected MultilineColoredTreeCellRenderer() { + setWrapText(true); + addPropertyChangeListener(new PropertyChangeListener() { + @Override + public void propertyChange(PropertyChangeEvent evt) { + if (FONT_PROPERTY_NAME.equalsIgnoreCase(evt.getPropertyName())) { + onFontChanged(); + } + } + }); + } + + protected void setMinHeight(int height) { + myMinHeight = height; + } + + private void onFontChanged() { + resetTextLayoutCache(); + } + + @NotNull + private FontMetrics getCurrFontMetrics() { + return getFontMetrics(getFont()); + } + + public void setText(@NotNull String[] lines, @Nullable String prefix) { + myPrefix = prefix; + for (int i = 0; i < lines.length; i++) { + append(lines[i]); + if (i < lines.length - 1) { + appendLineBreak(); + } + } + } + + @Override + protected void beforePaintText(@NotNull Graphics g, int x, int textBaseLine) { + if (!StringUtil.isEmpty(myPrefix)) { + g.drawString(myPrefix, x - myPrefixWidth + 1, textBaseLine); + } + } + + @NotNull + @Override + public Dimension getMinimumSize() { + Dimension preferredSize = getPreferredSize(); + Dimension result = new Dimension(preferredSize); + Insets padding = getIpad(); + result.width = Math.max(result.width, padding.left + padding.right); + result.height = Math.max(myMinHeight, Math.max(result.height, padding.top + padding.bottom)); + return result; + } + + private static int getChildIndent(@NotNull JTree tree) { + TreeUI newUI = tree.getUI(); + if (newUI instanceof BasicTreeUI) { + BasicTreeUI ui = (BasicTreeUI)newUI; + return ui.getLeftChildIndent() + ui.getRightChildIndent(); + } + else { + return ((Integer)UIUtil.getTreeLeftChildIndent()).intValue() + ((Integer)UIUtil.getTreeRightChildIndent()).intValue(); + } + } + + private static int getAvailableWidth(@NotNull Object forValue, @NotNull JTree tree) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode)forValue; + int busyRoom = tree.getInsets().left + tree.getInsets().right + getChildIndent(tree) * node.getLevel(); + return tree.getVisibleRect().width - busyRoom - 2; + } + + + + protected abstract void initComponent(@NotNull JTree tree, + @Nullable Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus); + + public void customizeCellRenderer(@NotNull JTree tree, + @NotNull Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) + { + setFont(UIUtil.getTreeFont()); + + initComponent(tree, value, selected, expanded, leaf, row, hasFocus); + + int availWidth = getAvailableWidth(value, tree); + if (availWidth > 0) { + setSize(availWidth, 100); // height will be calculated automatically + } + + int leftInset = myLabelInsets.left; + + Icon icon = getIcon(); + if (icon != null) { + leftInset += icon.getIconWidth() + 2; + } + + if (!StringUtil.isEmpty(myPrefix)) { + myPrefixWidth = getCurrFontMetrics().stringWidth(myPrefix) + 5; + leftInset += myPrefixWidth; + } + + setIpad(new Insets(myLabelInsets.top, leftInset, myLabelInsets.bottom, myLabelInsets.right)); + if (icon != null) { + setMinHeight(icon.getIconHeight()); + } + else { + setMinHeight(1); + } + + setSize(getPreferredSize()); + resetTextLayoutCache(); + } + + @SuppressWarnings("IfStatementWithIdenticalBranches") + @Override + public final Component getTreeCellRendererComponent(@NotNull JTree tree, + @NotNull Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) + { + myTree = tree; + + clear(); + + mySelected = selected; + myFocusedCalculated = false; + + // We paint background if and only if tree path is selected and tree has focus. + // If path is selected and tree is not focused then we just paint focused border. + if (UIUtil.isFullRowSelectionLAF()) { + setBackground(selected ? UIUtil.getTreeSelectionBackground() : null); + } + else if (tree.getUI() instanceof WideSelectionTreeUI && ((WideSelectionTreeUI)tree.getUI()).isWideSelection()) { + setPaintFocusBorder(false); + if (selected) { + setBackground(hasFocus ? UIUtil.getTreeSelectionBackground() : UIUtil.getTreeUnfocusedSelectionBackground()); + } + } + else if (selected) { + setPaintFocusBorder(true); + if (isFocused()) { + setBackground(UIUtil.getTreeSelectionBackground()); + } + else { + setBackground(null); + } + } + else { + setBackground(null); + } + + if (value instanceof LoadingNode) { + setForeground(JBColor.GRAY); + setIcon(LOADING_NODE_ICON); + } + else { + setForeground(tree.getForeground()); + setIcon(null); + } + + if (UIUtil.isUnderGTKLookAndFeel()) { + super.setOpaque(false); // avoid nasty background + super.setIconOpaque(false); + } + else if (UIUtil.isUnderNimbusLookAndFeel() && selected && hasFocus) { + super.setOpaque(false); // avoid erasing Nimbus focus frame + super.setIconOpaque(false); + } + else if (tree.getUI() instanceof WideSelectionTreeUI && ((WideSelectionTreeUI)tree.getUI()).isWideSelection()) { + super.setOpaque(false); // avoid erasing Nimbus focus frame + super.setIconOpaque(false); + } + else { + super.setOpaque(myOpaque || selected && hasFocus || selected && isFocused()); // draw selection background even for non-opaque tree + } + + if (tree.getUI() instanceof WideSelectionTreeUI && UIUtil.isUnderAquaBasedLookAndFeel()) { + setMyBorder(null); + setIpad(new Insets(0, 2, 0, 2)); + } + + customizeCellRenderer(tree, value, selected, expanded, leaf, row, hasFocus); + + return this; + } + + @NotNull + public static JScrollPane installRenderer(@NotNull final JTree tree, @NotNull final MultilineColoredTreeCellRenderer renderer) { + final TreeCellRenderer defaultRenderer = tree.getCellRenderer(); + + JScrollPane scrollPane = new JBScrollPane(tree){ + private int myAddRemoveCounter = 0; + private boolean myShouldResetCaches = false; + @Override + public void setSize(Dimension d) { + boolean isChanged = getWidth() != d.width || myShouldResetCaches; + super.setSize(d); + if (isChanged) resetCaches(); + } + + @Override + public void setBounds(int x, int y, int width, int height) { + boolean isChanged = width != getWidth() || myShouldResetCaches; + super.setBounds(x, y, width, height); + if (isChanged) resetCaches(); + } + + private void resetCaches() { + resetHeightCache(tree, defaultRenderer, renderer); + myShouldResetCaches = false; + } + + @Override + public void addNotify() { + super.addNotify(); + if (myAddRemoveCounter == 0) myShouldResetCaches = true; + myAddRemoveCounter++; + } + + @Override + public void removeNotify() { + super.removeNotify(); + myAddRemoveCounter--; + } + }; + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); + + tree.setCellRenderer(renderer); + + scrollPane.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + resetHeightCache(tree, defaultRenderer, renderer); + } + + @Override + public void componentShown(ComponentEvent e) { + // componentResized not called when adding to opened tool window. + // Seems to be BUG#4765299, however I failed to create same code to reproduce it. + // To reproduce it with IDEA: 1. remove this method, 2. Start any Ant task, 3. Keep message window open 4. start Ant task again. + resetHeightCache(tree, defaultRenderer, renderer); + } + }); + + return scrollPane; + } + + private static void resetHeightCache(@NotNull final JTree tree, + @NotNull final TreeCellRenderer defaultRenderer, + @NotNull final MultilineColoredTreeCellRenderer renderer) { + tree.setCellRenderer(defaultRenderer); + tree.setCellRenderer(renderer); + } + + @Nullable + public JTree getTree() { + return myTree; + } + + protected final boolean isFocused() { + if (!myFocusedCalculated) { + myFocused = calcFocusedState(); + myFocusedCalculated = true; + } + return myFocused; + } + + protected boolean calcFocusedState() { + return myTree != null && myTree.hasFocus(); + } + + @Override + public void setOpaque(boolean isOpaque) { + myOpaque = isOpaque; + super.setOpaque(isOpaque); + } + + @Nullable + @Override + public Font getFont() { + Font font = super.getFont(); + + // Cell renderers could have no parent and no explicit set font. + // Take tree font in this case. + if (font != null) return font; + JTree tree = getTree(); + return tree != null ? tree.getFont() : null; + } + + /** + * When the item is selected then we use default tree's selection foreground. + * It guaranties readability of selected text in any LAF. + */ + @Override + public void append(@NotNull @Nls String fragment, @NotNull SimpleTextAttributes attributes, boolean isMainText) { + if (mySelected && isFocused()) { + super.append(fragment, new SimpleTextAttributes(attributes.getStyle(), UIUtil.getTreeSelectionForeground()), isMainText); + } + else if (mySelected && UIUtil.isUnderAquaBasedLookAndFeel()) { + super.append(fragment, new SimpleTextAttributes(attributes.getStyle(), UIUtil.getTreeForeground()), isMainText); + } + else { + super.append(fragment, attributes, isMainText); + } + } +} diff --git a/android/src/com/android/tools/idea/ui/WrapAwareColoredComponent.java b/android/src/com/android/tools/idea/ui/WrapAwareColoredComponent.java new file mode 100644 index 00000000000..d2d7b6e27d5 --- /dev/null +++ b/android/src/com/android/tools/idea/ui/WrapAwareColoredComponent.java @@ -0,0 +1,1155 @@ +/* + * 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.ui; + +import com.google.common.collect.Lists; +import com.intellij.ide.BrowserUtil; +import com.intellij.openapi.application.Application; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.ui.*; +import com.intellij.util.ui.GraphicsUtil; +import com.intellij.util.ui.UIUtil; +import gnu.trove.TIntArrayList; +import gnu.trove.TIntIntHashMap; +import gnu.trove.TIntObjectHashMap; +import org.intellij.lang.annotations.JdkConstants; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.accessibility.Accessible; +import javax.accessibility.AccessibleContext; +import javax.accessibility.AccessibleRole; +import javax.accessibility.AccessibleStateSet; +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.tree.TreeCellRenderer; +import java.awt.*; +import java.lang.IllegalArgumentException; +import java.util.*; +import java.util.List; + +@SuppressWarnings({"NonPrivateFieldAccessedInSynchronizedContext", "FieldAccessedSynchronizedAndUnsynchronized", "UnusedDeclaration"}) +public class WrapAwareColoredComponent extends JComponent implements Accessible, ColoredTextContainer { + private static final boolean isOracleRetina = UIUtil.isRetina() && SystemInfo.isOracleJvm; + + private static final Logger LOG = Logger.getInstance("#com.intellij.ui.SimpleColoredComponent"); + + public static final Color SHADOW_COLOR = new JBColor(new Color(250, 250, 250, 140), Gray._0.withAlpha(50)); + public static final Color STYLE_SEARCH_MATCH_BACKGROUND = SHADOW_COLOR; //api compatibility + public static final int FRAGMENT_ICON = -2; + + + @NotNull private final List<String> myFragments = Lists.newArrayListWithCapacity(3); + @NotNull private final List<SimpleTextAttributes> myAttributes = Lists.newArrayListWithCapacity(3); + @NotNull private final TIntObjectHashMap<TIntArrayList> myBreakOffsets = new TIntObjectHashMap<TIntArrayList>(); + @NotNull private final TIntIntHashMap myLineHeights = new TIntIntHashMap(); + @NotNull private final Dimension myTextDimensions = new Dimension(); + @NotNull private final WrapsAwareTextHelper myTextHelper = new WrapsAwareTextHelper(this); + + @NotNull private final String myLineBreakMarker; + + /** + * Internal padding + */ + @NotNull private Insets myIpad = new Insets(1, 2, 1, 2); + + /** + * This is the border around the text. For example, text can have a border + * if the component represents a selected item in a focused JList. + * Border can be <code>null</code>. + */ + @Nullable private Border myBorder = new MyBorder(); + + @Nullable private List<Object> myFragmentTags; + + /** + * Component's icon. It can be <code>null</code>. + */ + @Nullable private Icon myIcon; + + /** + * Holds value of the last width limit used for {@link #computeTextDimension(Font, boolean, int) calculating text dimensions}. + * <p/> + * E.g. we can safely use {@link #myTextDimensions cached text dimensions} if value of this field is not null and equals + * to the {@link #getWidth() current width}. + */ + @Nullable private Integer myLastUsedWidthLimit; + + /** + * Gap between icon and text. It is used only if icon is defined. + */ + protected int myIconTextGap = 2; + /** + * Defines whether the focus border around the text is painted or not. + * For example, text can have a border if the component represents a selected item + * in focused JList. + */ + private boolean myPaintFocusBorder; + /** + * Defines whether the focus border around the text extends to icon or not + */ + private boolean myFocusBorderAroundIcon; + + private int myMainTextLastIndex = -1; + + private final TIntIntHashMap myFixedWidths = new TIntIntHashMap(10); + + @JdkConstants.HorizontalAlignment private int myTextAlign = SwingConstants.LEFT; + + private boolean myIconOpaque = false; + + private boolean myAutoInvalidate = !(this instanceof TreeCellRenderer); + + private final AccessibleContext myContext = new MyAccessibleContext(); + + private boolean myIconOnTheRight = false; + private boolean myTransparentIconBackground; + private boolean myWrapText; + + public WrapAwareColoredComponent() { + setOpaque(true); + WrapsAwareTextHelper.appendLineBreak(myFragments); + myLineBreakMarker = myFragments.get(0); + myFragments.clear(); + } + + @NotNull + public ColoredIterator iterator() { + return new MyIterator(); + } + + public boolean isIconOnTheRight() { + return myIconOnTheRight; + } + + public void setIconOnTheRight(boolean iconOnTheRight) { + myIconOnTheRight = iconOnTheRight; + } + + @NotNull + public WrapAwareColoredComponent appendLineBreak() { + WrapsAwareTextHelper.appendLineBreak(myFragments); + myAttributes.add(SimpleTextAttributes.REGULAR_ATTRIBUTES); + myMainTextLastIndex = myFragments.size() - 1; + resetTextLayoutCache(); + return this; + } + + @NotNull + public final WrapAwareColoredComponent append(@NotNull String fragment) { + append(fragment, SimpleTextAttributes.REGULAR_ATTRIBUTES); + return this; + } + + /** + * Appends string fragments to existing ones. Appended string + * will have specified <code>attributes</code>. + * @param fragment text fragment + * @param attributes text attributes + */ + @Override + public final void append(@NotNull final String fragment, @NotNull final SimpleTextAttributes attributes) { + append(fragment, attributes, myMainTextLastIndex < 0); + } + + /** + * Appends string fragments to existing ones. Appended string + * will have specified <code>attributes</code>. + * @param fragment text fragment + * @param attributes text attributes + * @param isMainText main text of not + */ + public void append(@NotNull final String fragment, @NotNull final SimpleTextAttributes attributes, boolean isMainText) { + _append(fragment, attributes, isMainText); + revalidateAndRepaint(); + } + + private synchronized void _append(@NotNull final String fragment, @NotNull final SimpleTextAttributes attributes, boolean isMainText) { + myFragments.add(fragment); + myAttributes.add(attributes); + if (isMainText) { + myMainTextLastIndex = myFragments.size() - 1; + } + resetTextLayoutCache(); + } + + private void revalidateAndRepaint() { + if (myAutoInvalidate) { + revalidate(); + } + + repaint(); + } + + @Override + public void append(@NotNull final String fragment, @NotNull final SimpleTextAttributes attributes, Object tag) { + _append(fragment, attributes, tag); + revalidateAndRepaint(); + } + + private synchronized void _append(@NotNull String fragment, @NotNull SimpleTextAttributes attributes, @Nullable Object tag) { + append(fragment, attributes); + if (myFragmentTags == null) { + myFragmentTags = new ArrayList<Object>(); + } + while (myFragmentTags.size() < myFragments.size() - 1) { + myFragmentTags.add(null); + } + myFragmentTags.add(tag); + } + + public synchronized void appendFixedTextFragmentWidth(int width) { + final int alignIndex = myFragments.size()-1; + myFixedWidths.put(alignIndex, width); + } + + public void setTextAlign(@JdkConstants.HorizontalAlignment int align) { + myTextAlign = align; + } + + /** + * Clear all special attributes of <code>SimpleColoredComponent</code>. + * They are icon, text fragments and their attributes, "paint focus border". + */ + public void clear() { + _clear(); + revalidateAndRepaint(); + } + + private synchronized void _clear() { + myIcon = null; + myPaintFocusBorder = false; + myFragments.clear(); + myAttributes.clear(); + myFragmentTags = null; + myMainTextLastIndex = -1; + myFixedWidths.clear(); + resetTextLayoutCache(); + } + + public void resetTextLayoutCache() { + myLastUsedWidthLimit = null; + myBreakOffsets.clear(); + } + + /** + * @return component's icon. This method returns <code>null</code> + * if there is no icon. + */ + @Nullable + public final Icon getIcon() { + return myIcon; + } + + /** + * Sets a new component icon + * @param icon icon + */ + @Override + public final void setIcon(@Nullable final Icon icon) { + myIcon = icon; + revalidateAndRepaint(); + } + + /** + * @return "leave" (internal) internal paddings of the component + */ + @NotNull + public Insets getIpad() { + return myIpad; + } + + /** + * Sets specified internal paddings + * @param ipad insets + */ + public void setIpad(@NotNull Insets ipad) { + myIpad = ipad; + + revalidateAndRepaint(); + } + + /** + * @return gap between icon and text + */ + public int getIconTextGap() { + return myIconTextGap; + } + + /** + * Sets a new gap between icon and text + * + * @param iconTextGap the gap between text and icon + * @throws IllegalArgumentException + * if the <code>iconTextGap</code> + * has a negative value + */ + public void setIconTextGap(final int iconTextGap) { + if (iconTextGap < 0) { + throw new IllegalArgumentException("wrong iconTextGap: " + iconTextGap); + } + myIconTextGap = iconTextGap; + + revalidateAndRepaint(); + } + + @Nullable + public Border getMyBorder() { + return myBorder; + } + + public void setMyBorder(@Nullable Border border) { + myBorder = border; + } + + /** + * Sets whether focus border is painted or not + * @param paintFocusBorder <code>true</code> or <code>false</code> + */ + protected final void setPaintFocusBorder(final boolean paintFocusBorder) { + myPaintFocusBorder = paintFocusBorder; + + repaint(); + } + + /** + * Sets whether focus border extends to icon or not. If so then + * component also extends the selection. + * @param focusBorderAroundIcon <code>true</code> or <code>false</code> + */ + protected final void setFocusBorderAroundIcon(final boolean focusBorderAroundIcon) { + myFocusBorderAroundIcon = focusBorderAroundIcon; + + repaint(); + } + + public boolean isIconOpaque() { + return myIconOpaque; + } + + public void setIconOpaque(final boolean iconOpaque) { + myIconOpaque = iconOpaque; + + repaint(); + } + + @Override + @NotNull + public Dimension getPreferredSize() { + return computePreferredSize(false); + + } + + @Override + @NotNull + public Dimension getMinimumSize() { + return computePreferredSize(false); + } + + @Nullable + public synchronized Object getFragmentTag(int index) { + if (myFragmentTags != null && index < myFragmentTags.size()) { + return myFragmentTags.get(index); + } + return null; + } + + @NotNull + public final synchronized Dimension computePreferredSize(final boolean mainTextOnly) { + // Calculate width + int width = myIpad.left; + + if (myIcon != null) { + width += myIcon.getIconWidth() + myIconTextGap; + } + + final Insets borderInsets = myBorder != null ? myBorder.getBorderInsets(this) : new Insets(0, 0, 0, 0); + width += borderInsets.left; + + Font font = getFont(); + if (font == null) { + font = UIUtil.getLabelFont(); + } + + LOG.assertTrue(font != null); + + int height = myIpad.top + myIpad.bottom; + width += myIpad.right + borderInsets.right; + // Take into account that the component itself can have a border + final Insets insets = getInsets(); + if (insets != null) { + width += insets.left + insets.right; + height += insets.top + insets.bottom; + } + + if (isOracleRetina) { + width++; //todo[kb] remove when IDEA-108760 will be fixed + } + assert font != null; + Dimension textDimension = computeTextDimension(font, mainTextOnly, myWrapText ? getWidth() - width : 0); + width += textDimension.width; + + int textHeight = textDimension.height; + textHeight += borderInsets.top + borderInsets.bottom; + + if (myIcon != null) { + height += Math.max(myIcon.getIconHeight(), textHeight); + } + else { + height += textHeight; + } + + return new Dimension(width, height); + } + + @NotNull + private Dimension computeTextDimension(@NotNull Font font, final boolean mainTextOnly, int widthLimit) { + if (myLastUsedWidthLimit != null && widthLimit == myLastUsedWidthLimit) { + return myTextDimensions; + } + final List<String> fragmentsToUse; + final List<SimpleTextAttributes> attributesToUse; + if (mainTextOnly && myMainTextLastIndex >= 0 && myMainTextLastIndex < myFragments.size() - 1) { + fragmentsToUse = myFragments.subList(0, myMainTextLastIndex); + attributesToUse = myAttributes.subList(0, myMainTextLastIndex); + } + else { + fragmentsToUse = myFragments; + attributesToUse = myAttributes; + } + myBreakOffsets.clear(); + myLineHeights.clear(); + myTextHelper.wrap(fragmentsToUse, attributesToUse, font, myFixedWidths, widthLimit, myTextDimensions, myBreakOffsets, myLineHeights); + myLastUsedWidthLimit = widthLimit; + return myTextDimensions; + } + + /** + * Returns the index of text fragment at the specified X offset. + * + * @param x the offset + * @return the index of the fragment, {@link #FRAGMENT_ICON} if the icon is at the offset, or -1 if nothing is there. + */ + public int findFragmentAt(int x, int y) { + // Make sure text wraps are properly calculated + computePreferredSize(false); + + int curX = myIpad.left; + if (myIcon != null) { + final int iconStartX; + if (myIconOnTheRight) { + iconStartX = curX + myTextDimensions.width + myIconTextGap; + } + else { + iconStartX = curX; + curX += myIcon.getIconWidth() + myIconTextGap; + } + if (x >= iconStartX && x < iconStartX + myIcon.getIconWidth()) { + return FRAGMENT_ICON; + } + } + + if (x - curX >= 0 && x - curX < myTextDimensions.width && y >= 0 && y <= myTextDimensions.height) { + return myTextHelper.mapFragment(myFragments, myAttributes, myFixedWidths, myBreakOffsets, myLineHeights, getFont(), x - curX, y); + } + else { + return -1; + } + } + + @Nullable + public Object getFragmentTagAt(int x, int y) { + int index = findFragmentAt(x, y); + return index < 0 ? null : getFragmentTag(index); + } + + @NotNull + protected JLabel formatToLabel(@NotNull JLabel label) { + label.setIcon(myIcon); + + if (!myFragments.isEmpty()) { + final StringBuilder text = new StringBuilder(); + text.append("<html><body style=\"white-space:nowrap\">"); + + for (int i = 0; i < myFragments.size(); i++) { + final String fragment = myFragments.get(i); + final SimpleTextAttributes attributes = myAttributes.get(i); + final Object tag = getFragmentTag(i); + if (tag instanceof BrowserLauncherTag) { + formatLink(text, fragment, attributes, ((BrowserLauncherTag)tag).myUrl); + } + else { + formatText(text, fragment, attributes); + } + } + + text.append("</body></html>"); + label.setText(text.toString()); + } + + return label; + } + + static void formatText(@NotNull StringBuilder builder, @NotNull String fragment, @NotNull SimpleTextAttributes attributes) { + if (!fragment.isEmpty()) { + builder.append("<span"); + formatStyle(builder, attributes); + builder.append('>').append(convertFragment(fragment)).append("</span>"); + } + } + + static void formatLink(@NotNull StringBuilder builder, @NotNull String fragment, @NotNull SimpleTextAttributes attributes, @NotNull String url) { + if (!fragment.isEmpty()) { + builder.append("<a href=\"").append(StringUtil.replace(url, "\"", "%22")).append("\""); + formatStyle(builder, attributes); + builder.append('>').append(convertFragment(fragment)).append("</a>"); + } + } + + @NotNull + private static String convertFragment(@NotNull String fragment) { + return StringUtil.escapeXml(fragment).replaceAll("\\\\n", "<br>"); + } + + private static void formatStyle(final StringBuilder builder, final SimpleTextAttributes attributes) { + final Color fgColor = attributes.getFgColor(); + final Color bgColor = attributes.getBgColor(); + final int style = attributes.getStyle(); + + final int pos = builder.length(); + if (fgColor != null) { + builder.append("color:#").append(Integer.toString(fgColor.getRGB() & 0xFFFFFF, 16)).append(';'); + } + if (bgColor != null) { + builder.append("background-color:#").append(Integer.toString(bgColor.getRGB() & 0xFFFFFF, 16)).append(';'); + } + if ((style & SimpleTextAttributes.STYLE_BOLD) != 0) { + builder.append("font-weight:bold;"); + } + if ((style & SimpleTextAttributes.STYLE_ITALIC) != 0) { + builder.append("font-style:italic;"); + } + if ((style & SimpleTextAttributes.STYLE_UNDERLINE) != 0) { + builder.append("text-decoration:underline;"); + } + else if ((style & SimpleTextAttributes.STYLE_STRIKEOUT) != 0) { + builder.append("text-decoration:line-through;"); + } + if (builder.length() > pos) { + builder.insert(pos, " style=\""); + builder.append('"'); + } + } + + @Override + protected void paintComponent(@NotNull final Graphics g) { + try { + _doPaint(g); + } + catch (RuntimeException e) { + LOG.error(logSwingPath(), e); + throw e; + } + } + + private synchronized void _doPaint(@NotNull final Graphics g) { + checkCanPaint(g); + doPaint((Graphics2D)g); + } + + protected void doPaint(@NotNull final Graphics2D g) { + int offset = 0; + final Icon icon = myIcon; // guard against concurrent modification (IDEADEV-12635) + if (icon != null && !myIconOnTheRight) { + doPaintIcon(g, icon, 0); + offset += myIpad.left + icon.getIconWidth() + myIconTextGap; + } + + doPaintTextBackground(g, offset); + offset = doPaintText(g, offset, myFocusBorderAroundIcon || icon == null); + if (icon != null && myIconOnTheRight) { + doPaintIcon(g, icon, offset); + } + } + + private void doPaintTextBackground(@NotNull Graphics2D g, int offset) { + if (isOpaque() || shouldDrawBackground()) { + paintBackground(g, offset, getWidth() - offset, getHeight()); + } + } + + protected void paintBackground(@NotNull Graphics2D g, int x, int width, int height) { + g.setColor(getBackground()); + g.fillRect(x, 0, width, height); + } + + protected void doPaintIcon(@NotNull Graphics2D g, @NotNull Icon icon, int offset) { + final Container parent = getParent(); + Color iconBackgroundColor = null; + if ((isOpaque() || isIconOpaque()) && !isTransparentIconBackground()) { + if (parent != null && !myFocusBorderAroundIcon && !UIUtil.isFullRowSelectionLAF()) { + iconBackgroundColor = parent.getBackground(); + } + else { + iconBackgroundColor = getBackground(); + } + } + + if (iconBackgroundColor != null) { + g.setColor(iconBackgroundColor); + g.fillRect(offset, 0, icon.getIconWidth() + myIpad.left + myIconTextGap, getHeight()); + } + + paintIcon(g, icon, offset + myIpad.left); + } + + protected int doPaintText(@NotNull Graphics2D g, int offset, boolean focusAroundIcon) { + // Force using right text dimensions. + computePreferredSize(false); + + // If there is no icon, then we have to add left internal padding + if (offset == 0) { + offset = myIpad.left; + } + + int textStart = offset; + if (myBorder != null) { + offset += myBorder.getBorderInsets(this).left; + } + + final List<Object[]> searchMatches = new ArrayList<Object[]>(); + + UIUtil.applyRenderingHints(g); + applyAdditionalHints(g); + final Font ownFont = getFont(); + if (ownFont != null) { + offset += computeTextAlignShift(ownFont); + } + int baseSize = ownFont != null ? ownFont.getSize() : g.getFont().getSize(); + boolean wasSmaller = false; + int x = offset; + int y = 0; + int line = 0; + boolean beforePaintTextCalled = false; + for (int i = 0; i < myFragments.size(); i++) { + final SimpleTextAttributes attributes = myAttributes.get(i); + + Font font = g.getFont(); + boolean isSmaller = attributes.isSmaller(); + if (font.getStyle() != attributes.getFontStyle() || isSmaller != wasSmaller) { // derive font only if it is necessary + font = font.deriveFont(attributes.getFontStyle(), isSmaller ? UIUtil.getFontSize(UIUtil.FontSize.SMALL) : baseSize); + } + wasSmaller = isSmaller; + + g.setFont(font); + final FontMetrics metrics = g.getFontMetrics(font); + int lineHeight = myLineHeights.get(line++); + if (lineHeight <= 0) { + lineHeight = metrics.getHeight(); + } + + final String wholeFragmentTextToDraw = myFragments.get(i); + if (myLineBreakMarker.equals(wholeFragmentTextToDraw)) { + y += lineHeight; + x = offset; + continue; + } + + Color color = attributes.getFgColor(); + if (color == null) { // in case if color is not defined we have to get foreground color from Swing hierarchy + color = getForeground(); + } + if (!isEnabled()) { + color = UIUtil.getInactiveTextColor(); + } + g.setColor(color); + + for (TextRange range = nextFragmentLineRange(i, null); range != null; range = nextFragmentLineRange(i, range)) { + if (range.getStartOffset() > 0) { // This is not the first fragment's part, i.e. it was long enough to be split into multiple lines. + lineHeight = myLineHeights.get(++line); + if (lineHeight <= 0) { + lineHeight = metrics.getHeight(); + } + x = offset; + y += lineHeight; + } + String textToDraw = wholeFragmentTextToDraw.substring(range.getStartOffset(), range.getEndOffset()); + final int textWidth = isOracleRetina ? GraphicsUtil.stringWidth(textToDraw, font) : metrics.stringWidth(textToDraw); + final int textBaseline = y + getTextBaseLine(metrics, lineHeight); + if (!beforePaintTextCalled) { + beforePaintText(g, x, textBaseline); + } + + final Color bgColor = attributes.isSearchMatch() ? null : attributes.getBgColor(); + if ((attributes.isOpaque() || isOpaque()) && bgColor != null) { + g.setColor(bgColor); + g.fillRect(x, y, textWidth, lineHeight); + } + + if (!attributes.isSearchMatch()) { + if (shouldDrawMacShadow()) { + g.setColor(SHADOW_COLOR); + g.drawString(textToDraw, x, textBaseline + 1); + } + + if (shouldDrawDimmed()) { + color = ColorUtil.dimmer(color); + } + + g.setColor(color); + g.drawString(textToDraw, x, textBaseline); + } + + // 1. Strikeout effect + if (attributes.isStrikeout()) { + final int strikeOutAt = textBaseline + (metrics.getDescent() - metrics.getAscent()) / 2; + UIUtil.drawLine(g, x, strikeOutAt, x + textWidth, strikeOutAt); + } + // 2. Waved effect + if (attributes.isWaved()) { + if (attributes.getWaveColor() != null) { + g.setColor(attributes.getWaveColor()); + } + final int wavedAt = textBaseline + 1; + for (int waveX = x; waveX <= x + textWidth; waveX += 4) { + UIUtil.drawLine(g, waveX, wavedAt, waveX + 2, wavedAt + 2); + UIUtil.drawLine(g, waveX + 3, wavedAt + 1, waveX + 4, wavedAt); + } + } + // 3. Underline + if (attributes.isUnderline()) { + final int underlineAt = textBaseline + 1; + UIUtil.drawLine(g, x, underlineAt, x + textWidth, underlineAt); + } + // 4. Bold Dotted Line + if (attributes.isBoldDottedLine()) { + final int dottedAt = SystemInfo.isMac ? textBaseline : textBaseline + 1; + final Color lineColor = attributes.getWaveColor(); + UIUtil.drawBoldDottedLine(g, x, x + textWidth, dottedAt, bgColor, lineColor, isOpaque()); + } + + if (attributes.isSearchMatch()) { + searchMatches.add(new Object[]{x, x + textWidth, textBaseline, textToDraw, g.getFont(), lineHeight}); + } + + final int fixedWidth = myFixedWidths.get(i); + if (fixedWidth > 0 && textWidth < fixedWidth) { + x += fixedWidth; + } + else { + x += textWidth; + } + } + } + + // Paint focus border around the text and icon (if necessary) + if (myPaintFocusBorder && myBorder != null) { + if (focusAroundIcon) { + myBorder.paintBorder(this, g, 0, 0, getWidth(), getHeight()); + } + else { + myBorder.paintBorder(this, g, textStart, 0, getWidth() - textStart, getHeight()); + } + } + + // draw search matches after all + for (final Object[] info : searchMatches) { + UIUtil.drawSearchMatch(g, (Integer)info[0], (Integer)info[1], (Integer)info[5]); + g.setFont((Font)info[4]); + + if (shouldDrawMacShadow()) { + g.setColor(SHADOW_COLOR); + g.drawString((String)info[3], (Integer)info[0], (Integer)info[2] + 1); + } + + g.setColor(new JBColor(Gray._50, Gray._0)); + g.drawString((String)info[3], (Integer)info[0], (Integer)info[2]); + } + return offset; + } + + protected void beforePaintText(@NotNull Graphics g, int x, int textBaseLine) { + } + + /** + * There is a possible case that particular text fragment is displayed at more than one line. It's assumed that information about + * such inner fragment line break offsets is stored at the {@link #myBreakOffsets} field. + * <p/> + * This helper method assumes to be used during iterative fragment parts processing, i.e. it receives a text range within the target + * fragment (identified by it's index at the {@link #myFragments} collection) and returns text range for the next part of the fragment + * to be drawn + * + * @param fragmentIndex target fragment's index within the {@link #myFragments fragments collection} + * @param previousFragmentLineRange text range for the fragment's part used the last time (<code>null</code> value indicates that + * the fragment hasn't been used yet) + * @return text fragment for the fragment's part to be shown at new line (if any); <code>null</code> as an + * indication that the target fragment has been completely processed + */ + @Nullable + private TextRange nextFragmentLineRange(int fragmentIndex, @Nullable TextRange previousFragmentLineRange) { + TIntArrayList breakOffsets = myBreakOffsets.get(fragmentIndex); + String fragmentText = myFragments.get(fragmentIndex); + if (breakOffsets == null || breakOffsets.isEmpty()) { + if (previousFragmentLineRange == null) { + return TextRange.allOf(fragmentText); + } + else { + return null; + } + } + + if (previousFragmentLineRange == null) { + return TextRange.create(0, breakOffsets.get(0)); + } + + for (int i = 0; i < breakOffsets.size(); i++) { + if (breakOffsets.get(i) == previousFragmentLineRange.getEndOffset()) { + if (i < breakOffsets.size() - 1) { + return TextRange.create(previousFragmentLineRange.getEndOffset(), breakOffsets.get(i + 1)); + } + else { + return TextRange.create(previousFragmentLineRange.getEndOffset(), fragmentText.length()); + } + } + } + return null; + } + + private int computeTextAlignShift(@NotNull Font font) { + if (myTextAlign == SwingConstants.LEFT || myTextAlign == SwingConstants.LEADING) { + return 0; + } + + int componentWidth = getSize().width; + int excessiveWidth = componentWidth - computePreferredSize(false).width; + if (excessiveWidth <= 0) { + return 0; + } + + Dimension textDimension = computeTextDimension(font, false, myWrapText ? getWidth() : 0); + if (myTextAlign == SwingConstants.CENTER) { + return excessiveWidth / 2; + } + else if (myTextAlign == SwingConstants.RIGHT || myTextAlign == SwingConstants.TRAILING) { + return excessiveWidth; + } + return 0; + } + + protected boolean shouldDrawMacShadow() { + return false; + } + + protected boolean shouldDrawDimmed() { + return false; + } + + protected boolean shouldDrawBackground() { + return false; + } + + protected void paintIcon(@NotNull Graphics g, @NotNull Icon icon, int offset) { + final int y; + if (myLineHeights.size() <= 1) { + // Draw icon center-aligned in case one ore less text lines. + y = (getHeight() - icon.getIconHeight()) / 2; + } + else { + // Draw icon at the first text line instead. + if (icon.getIconHeight() > myLineHeights.get(0)) { + y = myIpad.top; + } + else { + y = myIpad.top + (myLineHeights.get(0) - icon.getIconHeight()) / 2; + } + } + icon.paintIcon(this, g, offset, y); + } + + protected void applyAdditionalHints(@NotNull Graphics g) { + } + + @Override + public int getBaseline(int width, int height) { + super.getBaseline(width, height); + return getTextBaseLine(getFontMetrics(getFont()), height); + } + + public boolean isTransparentIconBackground() { + return myTransparentIconBackground; + } + + public void setTransparentIconBackground(boolean transparentIconBackground) { + myTransparentIconBackground = transparentIconBackground; + } + + /** + * Instructs current component to display {@link #append(String) encapsulated text} in a way to avoid it to go beyond the horizontal + * visible area. + * <p/> + * Example: + * <pre> + * Say, we have a situation like below: + * + * | | + * | |<-- visible area + * | | + * |1234567|89 + * | | + * + * Wrapped text is shown as follows then: + * + * | | + * |1234567| + * |89 | + * | | + * </pre> + * + * @param wrapText a flag which indicates if target text shown by the current control should be wrapped + */ + public void setWrapText(boolean wrapText) { + if (myWrapText != wrapText) { + resetTextLayoutCache(); + } + myWrapText = wrapText; + } + + public static int getTextBaseLine(@NotNull FontMetrics metrics, final int height) { + return (height - metrics.getHeight()) / 2 + metrics.getAscent(); + } + + private static void checkCanPaint(@NotNull Graphics g) { + if (UIUtil.isPrinting(g)) return; + + /* wtf?? + if (!isDisplayable()) { + LOG.assertTrue(false, logSwingPath()); + } + */ + final Application application = ApplicationManager.getApplication(); + if (application != null) { + application.assertIsDispatchThread(); + } + else if (!SwingUtilities.isEventDispatchThread()) { + throw new RuntimeException(Thread.currentThread().toString()); + } + } + + @NotNull + private String logSwingPath() { + //noinspection HardCodedStringLiteral + final StringBuilder buffer = new StringBuilder("Components hierarchy:\n"); + for (Container c = this; c != null; c = c.getParent()) { + buffer.append('\n'); + buffer.append(c); + } + return buffer.toString(); + } + + protected void setBorderInsets(@NotNull Insets insets) { + if (myBorder instanceof MyBorder) { + ((MyBorder)myBorder).setInsets(insets); + } + + revalidateAndRepaint(); + } + + private static final class MyBorder implements Border { + @NotNull private Insets myInsets; + + public MyBorder() { + myInsets = new Insets(1, 1, 1, 1); + } + + public void setInsets(@NotNull final Insets insets) { + myInsets = insets; + } + + @Override + public void paintBorder(final Component c, final Graphics g, final int x, final int y, final int width, final int height) { + g.setColor(JBColor.BLACK); + UIUtil.drawDottedRectangle(g, x, y, x + width - 1, y + height - 1); + } + + @Override + public Insets getBorderInsets(@NotNull final Component c) { + return myInsets; + } + + @Override + public boolean isBorderOpaque() { + return true; + } + } + + @NotNull + public CharSequence getCharSequence(boolean mainOnly) { + List<String> fragments = mainOnly && myMainTextLastIndex > -1 && myMainTextLastIndex + 1 < myFragments.size()? + myFragments.subList(0, myMainTextLastIndex + 1) : myFragments; + return StringUtil.join(fragments, ""); + } + + @NotNull + @Override + public String toString() { + return getCharSequence(false).toString(); + } + + public void change(@NotNull Runnable runnable, boolean autoInvalidate) { + boolean old = myAutoInvalidate; + myAutoInvalidate = autoInvalidate; + try { + runnable.run(); + } finally { + myAutoInvalidate = old; + } + } + + @Override + public AccessibleContext getAccessibleContext() { + return myContext; + } + + private static class MyAccessibleContext extends AccessibleContext { + @Override + public AccessibleRole getAccessibleRole() { + return AccessibleRole.AWT_COMPONENT; + } + + @Override + public AccessibleStateSet getAccessibleStateSet() { + return new AccessibleStateSet(); + } + + @Override + public int getAccessibleIndexInParent() { + return 0; + } + + @Override + public int getAccessibleChildrenCount() { + return 0; + } + + @Nullable + @Override + public Accessible getAccessibleChild(int i) { + return null; + } + + @Override + public Locale getLocale() throws IllegalComponentStateException { + return Locale.getDefault(); + } + } + + public static class BrowserLauncherTag implements Runnable { + private final String myUrl; + + public BrowserLauncherTag(@NotNull String url) { + myUrl = url; + } + + @Override + public void run() { + BrowserUtil.browse(myUrl); + } + } + + public interface ColoredIterator extends Iterator<String> { + int getOffset(); + int getEndOffset(); + @NotNull + String getFragment(); + @NotNull + SimpleTextAttributes getTextAttributes(); + + int split(int offset, @NotNull SimpleTextAttributes attributes); + } + + private class MyIterator implements ColoredIterator { + int myIndex = -1; + int myOffset; + int myEndOffset; + + @Override + public int getOffset() { + return myOffset; + } + + @Override + public int getEndOffset() { + return myEndOffset; + } + + @NotNull + @Override + public String getFragment() { + return myFragments.get(myIndex); + } + + @NotNull + @Override + public SimpleTextAttributes getTextAttributes() { + return myAttributes.get(myIndex); + } + + @Override + public int split(int offset, @NotNull SimpleTextAttributes attributes) { + if (offset < 0 || offset > myEndOffset - myOffset) { + throw new IllegalArgumentException(offset + " is not within [0, " + (myEndOffset - myOffset) + "]"); + } + if (offset == myEndOffset - myOffset) { // replace + myAttributes.set(myIndex, attributes); + } + else if (offset > 0) { // split + String text = getFragment(); + myFragments.set(myIndex, text.substring(0, offset)); + myAttributes.add(myIndex, attributes); + myFragments.add(myIndex + 1, text.substring(offset)); + if (myFragmentTags != null && myFragmentTags.size() > myIndex) { + myFragmentTags.add(myIndex, myFragments.get(myIndex)); + } + myIndex ++; + } + myOffset += offset; + return myOffset; + } + + @Override + public boolean hasNext() { + return myIndex + 1 < myFragments.size(); + } + + @Override + public String next() { + myIndex ++; + myOffset = myEndOffset; + String text = getFragment(); + myEndOffset += text.length(); + return text; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/android/src/com/android/tools/idea/ui/WrapAwareLinkMouseListenerBase.java b/android/src/com/android/tools/idea/ui/WrapAwareLinkMouseListenerBase.java new file mode 100644 index 00000000000..0bb3fec6172 --- /dev/null +++ b/android/src/com/android/tools/idea/ui/WrapAwareLinkMouseListenerBase.java @@ -0,0 +1,67 @@ +/* + * 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.ui; + +import com.intellij.ui.ClickListener; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; + +public abstract class WrapAwareLinkMouseListenerBase <T> extends ClickListener implements MouseMotionListener { + + @Nullable + protected abstract T getTagAt(@NotNull MouseEvent e); + + @Override + public boolean onClick(@NotNull MouseEvent e, int clickCount) { + if (e.getButton() == MouseEvent.BUTTON1) { + handleTagClick(getTagAt(e)); + } + return false; + } + + protected void handleTagClick(@Nullable T tag) { + if (tag instanceof Runnable) { + ((Runnable)tag).run(); + } + } + + @Override + public void mouseDragged(MouseEvent e) { + } + + @Override + public void mouseMoved(@NotNull MouseEvent e) { + Component component = (Component)e.getSource(); + Object tag = getTagAt(e); + if (tag != null) { + component.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + } + else { + component.setCursor(Cursor.getDefaultCursor()); + } + } + + @Override + public void installOn(@NotNull Component component) { + super.installOn(component); + + component.addMouseMotionListener(this); + } +} diff --git a/android/src/com/android/tools/idea/ui/WrapAwareTreeNodePartListener.java b/android/src/com/android/tools/idea/ui/WrapAwareTreeNodePartListener.java new file mode 100644 index 00000000000..9961e45463b --- /dev/null +++ b/android/src/com/android/tools/idea/ui/WrapAwareTreeNodePartListener.java @@ -0,0 +1,65 @@ +/* + * 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.ui; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.awt.event.MouseEvent; + +public class WrapAwareTreeNodePartListener extends WrapAwareLinkMouseListenerBase { + private final TreeCellRenderer myRenderer; + //recalc optimization + @Nullable private DefaultMutableTreeNode myLastHitNode; + @Nullable private Component myRenderedComp; + + public WrapAwareTreeNodePartListener(@NotNull TreeCellRenderer renderer) { + myRenderer = renderer; + } + + @Override + protected Object getTagAt(@NotNull final MouseEvent e) { + final JTree tree = (JTree)e.getSource(); + final TreePath path = tree.getPathForLocation(e.getX(), e.getY()); + if (path != null) { + final DefaultMutableTreeNode treeNode = (DefaultMutableTreeNode)path.getLastPathComponent(); + if (myLastHitNode != treeNode) { + myLastHitNode = treeNode; + myRenderedComp = myRenderer.getTreeCellRendererComponent(tree, treeNode, false, false, treeNode.isLeaf(), -1, false); + } + + if (myRenderedComp != null) { + Rectangle bounds = tree.getPathBounds(path); + if (bounds != null) { + Component root = tree.getCellRenderer().getTreeCellRendererComponent(tree, treeNode, false, false, treeNode.isLeaf(), -1, false); + root.setSize(bounds.getSize()); + root.doLayout(); + if (root instanceof WrapAwareColoredComponent) { + WrapAwareColoredComponent component = (WrapAwareColoredComponent)root; + int fragmentIndex = component.findFragmentAt(e.getX() - bounds.x, e.getY() - bounds.y); + return fragmentIndex >= 0 ? component.getFragmentTag(fragmentIndex) : null; + } + } + } + } + return null; + } +} diff --git a/android/src/com/android/tools/idea/ui/WrapsAwareTextHelper.java b/android/src/com/android/tools/idea/ui/WrapsAwareTextHelper.java new file mode 100644 index 00000000000..233a0b8b4cf --- /dev/null +++ b/android/src/com/android/tools/idea/ui/WrapsAwareTextHelper.java @@ -0,0 +1,458 @@ +/* + * 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.ui; + +import com.intellij.openapi.util.SystemInfo; +import com.intellij.ui.SimpleTextAttributes; +import com.intellij.util.ui.GraphicsUtil; +import com.intellij.util.ui.UIUtil; +import gnu.trove.TIntArrayList; +import gnu.trove.TIntIntHashMap; +import gnu.trove.TIntObjectHashMap; +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.util.List; + +/** + * There is a possible case that we want to display particular text at UI and that available horizontal space is not large + * enough to show it as is. We might want to display a single text line on more than one visual line then. + * <p/> + * This class encapsulates that logic of representing a line of text on one or more visual line. It's main purpose is to separate + * that logic from UI processing in order to be able to cover it by tests. + * <p/> + * Thread-safe. + * + * @author Denis Zhdanov + * @since 10/09/14 + */ +public class WrapsAwareTextHelper { + + @SuppressWarnings("UndesirableClassUsage") + private static final Graphics2D ourGraphics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).createGraphics(); + + static { + GraphicsUtil.setupFractionalMetrics(ourGraphics); + GraphicsUtil.setupAntialiasing(ourGraphics, true, true); + } + + private static final String LINE_BREAK_MARKER = "___LINE_BREAK___"; + + @NotNull private final DimensionCalculator myDimensionCalculator; + + public WrapsAwareTextHelper(@NotNull final JComponent component) { + this(new DimensionCalculator() { + @Override + public void calculate(@NotNull String inText, @NotNull Font inFont, @NotNull Dimension outDimension) { + FontMetrics metrics = component.getFontMetrics(inFont); + if (UIUtil.isRetina() && SystemInfo.isOracleJvm) { + stringDimension(inText, inFont, outDimension); + } + else { + outDimension.width = metrics.stringWidth(inText); + outDimension.height = metrics.getHeight(); + } + } + + private void stringDimension(@NotNull String text, @NotNull Font font, @NotNull Dimension resultHolder) { + GraphicsUtil.setupAntialiasing(ourGraphics, true, true); + FontMetrics metrics = ourGraphics.getFontMetrics(font); + Rectangle2D bounds = metrics.getStringBounds(text, 0, text.length(), ourGraphics); + resultHolder.width = (int)bounds.getWidth(); + resultHolder.height = (int)bounds.getHeight(); + } + }); + } + + public WrapsAwareTextHelper(@NotNull DimensionCalculator dimensionCalculator) { + myDimensionCalculator = dimensionCalculator; + } + + public static void appendLineBreak(@NotNull List<String> textFragments) { + textFragments.add(LINE_BREAK_MARKER); + } + + /** + * Processes given styled text and fills out parameters with information on how to display the given text. + * + * @param inTextFragments text fragments to use + * @param inTextAttributes styled text attributes to use for the given test fragments (is assumed to be of the same size + * as the given text tokens collection) + * @param font base font to use for calculating given text dimensions + * @param inMinimumWidths collections which holds information about minimum width (in pixels) for the target text fragments + * (a key is a fragment's index and the value is its minimum width) + * @param inWidthLimit available width limit to use for calculation. Non-positive value means that no width limit should be used + * @param outTextDimension an object which will be filled by information about given styled text dimensions when this method returns + * @param outBreakOffsets a collection which will hold offsets where given text should visually break into a new line. + * Target text fragment's index at the given fragments collection is a key and list of offsets within that + * fragment is a value. Note that current method doesn't attempt to modify this collection over than + * to populate it + * @param outLineHeights a collection which holds information about text line heights + */ + public void wrap(@NotNull List<String> inTextFragments, + @NotNull List<SimpleTextAttributes> inTextAttributes, + @NotNull Font font, + @NotNull TIntIntHashMap inMinimumWidths, + int inWidthLimit, + @NotNull Dimension outTextDimension, + @NotNull TIntObjectHashMap<TIntArrayList> outBreakOffsets, + @NotNull TIntIntHashMap outLineHeights) + { + WrapContext context = new WrapContext(inTextFragments, outBreakOffsets, outLineHeights, font, inWidthLimit); + for (int i = 0; i < inTextAttributes.size(); i++) { + final String text = inTextFragments.get(i); + if (LINE_BREAK_MARKER.equals(text)) { + context.processTextLineBreak(); + continue; + } + + context.apply(inTextAttributes.get(i)); + myDimensionCalculator.calculate(text, context.font, context.tmp); + final int minWidth = inMinimumWidths.get(i); + if (minWidth > 0 && minWidth > context.tmp.width) { + context.processFixedFragmentWidth(minWidth, i); + continue; + } + context.processRegularFragment(i, 0); + } + outTextDimension.width = Math.max(context.textDimension.width, context.currentLineDimension.width); + outTextDimension.height = context.textDimension.height + context.currentLineDimension.height; + outLineHeights.put(context.line, context.currentLineDimension.height); + } + + /** + * Tries to map one of the given fragments to the given (x; y) coordinates. + * + * @param textFragments fragments to use + * @param textAttributes styled text attributes for the given fragments (this list is assumed to be of the same size as fragments list + * and holds attributes for the i-th fragment at the i-th position) + * @param minimumWidths collections which holds information about minimum width (in pixels) for the target text fragments + * (a key is a fragment's index and the value is its minimum width) + * @param breakOffsets a collection which will hold offsets where given text should visually break into a new line. + * Target text fragment's index at the given fragments collection is a key and list of offsets within that + * fragment is a value + * @param lineHeights a collection which holds information about text line heights + * @param font base font used for displaying given text fragments (implies text dimensions) + * @param x target x + * @param y target y + * @return index of the fragment at the given fragments list which corresponds to the given (x; y) (if found); + * negative value as an indication that there is no text fragment at the target point + */ + public int mapFragment(@NotNull List<String> textFragments, + @NotNull List<SimpleTextAttributes> textAttributes, + @NotNull TIntIntHashMap minimumWidths, + @NotNull TIntObjectHashMap<TIntArrayList> breakOffsets, + @NotNull TIntIntHashMap lineHeights, + @NotNull Font font, + int x, + int y) + { + if (lineHeights.isEmpty()) { + // No lines are there. + return -1; + } + MapContext context = new MapContext(textFragments, breakOffsets, lineHeights, font,minimumWidths, x, y); + for (int i = 0; i < textFragments.size(); i++) { + final String text = textFragments.get(i); + if (LINE_BREAK_MARKER.equals(text)) { + boolean canContinue = context.onLineBreak(); + if (!canContinue) { + return -1; + } + continue; + } + + context.apply(textAttributes.get(i)); + Boolean match = context.processTextFragment(i, 0); + if (match == Boolean.TRUE) { + return i; + } + else if (match == Boolean.FALSE) { + return -1; + } + } + return -1; + } + + public interface DimensionCalculator { + void calculate(@NotNull String inText, @NotNull Font inFont, @NotNull Dimension outDimension); + } + + private static class CommonContext { + + @NotNull final Dimension tmp = new Dimension(); + + int line; + + @NotNull protected final List<String> myTextFragments; + @NotNull protected final TIntObjectHashMap<TIntArrayList> myBreakOffsets; + @NotNull protected final TIntIntHashMap myLineHeights; + + @NotNull Font font; + + protected final int myBaseFontSize; + + private boolean myFontWasSmaller; + + CommonContext(@NotNull List<String> textFragments, + @NotNull TIntObjectHashMap<TIntArrayList> breakOffsets, + @NotNull TIntIntHashMap lineHeights, + @NotNull Font font) + { + myTextFragments = textFragments; + myBreakOffsets = breakOffsets; + myLineHeights = lineHeights; + this.font = font; + myBaseFontSize = font.getSize(); + } + + void apply(@NotNull SimpleTextAttributes attributes) { + boolean isSmaller = attributes.isSmaller(); + if (font.getStyle() != attributes.getFontStyle() || isSmaller != myFontWasSmaller) { // derive font only if it is necessary + font = font.deriveFont(attributes.getFontStyle(), isSmaller ? UIUtil.getFontSize(UIUtil.FontSize.SMALL) : myBaseFontSize); + } + myFontWasSmaller = isSmaller; + } + } + + private class WrapContext extends CommonContext { + + @NotNull final Dimension textDimension = new Dimension(); + @NotNull final Dimension currentLineDimension = new Dimension(); + + private final int myWidthLimit; + private final int mySampleWidth; + + WrapContext(@NotNull List<String> textFragments, + @NotNull TIntObjectHashMap<TIntArrayList> breakOffsets, + @NotNull TIntIntHashMap lineHeights, + @NotNull Font font, + int widthLimit) + { + super(textFragments, breakOffsets, lineHeights, font); + myWidthLimit = widthLimit; + myDimensionCalculator.calculate("W", font, tmp); + mySampleWidth = tmp.width; + } + + void processTextLineBreak() { + if (currentLineDimension.height <= 0) { + myDimensionCalculator.calculate("A", font, tmp); + currentLineDimension.height = tmp.height; + } + onNewLine(); + } + + void processFixedFragmentWidth(int fixedWidth, int textFragmentIndex) { + if (myWidthLimit > 0 && currentLineDimension.width + fixedWidth >= myWidthLimit) { + if (currentLineDimension.width > 0) { + onNewLine(); + storeBreakOffset(textFragmentIndex, 0); + currentLineDimension.width = fixedWidth; + currentLineDimension.height = tmp.height; + } + else { + currentLineDimension.width += fixedWidth; + currentLineDimension.height = Math.max(currentLineDimension.height, tmp.height); + onNewLine(); + if (textFragmentIndex < myTextFragments.size() - 1) { + storeBreakOffset(textFragmentIndex + 1, 0); + } + } + } + else { + currentLineDimension.width += fixedWidth; + currentLineDimension.height = Math.max(currentLineDimension.height, tmp.height); + } + } + + void processRegularFragment(int fragmentIndex, int textFragmentStartOffset) { + String fragmentText = myTextFragments.get(fragmentIndex); + myDimensionCalculator.calculate(fragmentText.substring(textFragmentStartOffset), font, tmp); + currentLineDimension.height = Math.max(currentLineDimension.height, tmp.height); + if (myWidthLimit <= 0 || currentLineDimension.width + tmp.width <= myWidthLimit) { + currentLineDimension.width += tmp.width; + return; + } + + int breakOffset = textFragmentStartOffset + (myWidthLimit - currentLineDimension.width) / mySampleWidth; + breakOffset = Math.min(fragmentText.length(), breakOffset); + myDimensionCalculator.calculate(fragmentText.substring(textFragmentStartOffset, breakOffset), font, tmp); + if (currentLineDimension.width + tmp.width <= myWidthLimit) { + currentLineDimension.width += tmp.width; + for (int i = breakOffset; i < fragmentText.length(); i++) { + myDimensionCalculator.calculate(fragmentText.substring(i, i + 1), font, tmp); + if (currentLineDimension.width + tmp.width > myWidthLimit) { + break; + } + currentLineDimension.width += tmp.width; + breakOffset++; + } + } + else { + for (--breakOffset; breakOffset > textFragmentStartOffset; breakOffset--) { + myDimensionCalculator.calculate(fragmentText.substring(textFragmentStartOffset, breakOffset), font, tmp); + if (currentLineDimension.width + tmp.width <= myWidthLimit) { + currentLineDimension.width += tmp.width; + break; + } + } + } + storeBreakOffset(fragmentIndex, breakOffset); + onNewLine(); + processRegularFragment(fragmentIndex, breakOffset); + } + + private void onNewLine() { + textDimension.width = Math.max(currentLineDimension.width, textDimension.width); + textDimension.height += currentLineDimension.height; + myLineHeights.put(line++, currentLineDimension.height); + currentLineDimension.width = currentLineDimension.height = 0; + } + + private void storeBreakOffset(int fragmentIndex, int breakOffset) { + TIntArrayList list = myBreakOffsets.get(fragmentIndex); + if (list == null) { + myBreakOffsets.put(fragmentIndex, list = new TIntArrayList()); + } + list.add(breakOffset); + } + } + + private class MapContext extends CommonContext { + + @NotNull private final TIntIntHashMap myMinimumWidths; + + private final int myTargetX; + private final int myTargetY; + private int myLineStartY; + private int myLineEndY; + private int myLineX; + + MapContext(@NotNull List<String> textFragments, + @NotNull TIntObjectHashMap<TIntArrayList> breakOffsets, + @NotNull TIntIntHashMap lineHeights, + @NotNull Font font, + @NotNull TIntIntHashMap minimumWidths, + int targetX, + int targetY) + { + super(textFragments, breakOffsets, lineHeights, font); + myMinimumWidths = minimumWidths; + myTargetX = targetX; + myTargetY = targetY; + myLineEndY = lineHeights.get(0); + } + + /** + * @return <code>true</code> if we can continue match process; <code>false</code> as an indication that no match will be found + */ + public boolean onLineBreak() { + int lineHeight = myLineHeights.get(++line); + if (lineHeight <= 0) { + // No more lines left. + return false; + } + myLineStartY = myLineEndY; + myLineEndY += lineHeight; + myLineX = 0; + return myTargetY >= myLineStartY; + } + + /** + * Asks to process target text fragment identifies by the given index. + * + * @param textFragmentIndex target text fragment's index + * @param fragmentStartOffset there is a possible case that particular fragment is split into more than one visual line. + * We need to process such fragments parts separately then. This arguments defines start offset + * of the target fragment's part to process + * @return {@link Boolean#TRUE} as an indication that target fragment matches target coordinates; + * {@link Boolean#FALSE} as an indication that no match will be found and the whole match process + * should be stopped; + * <code>null</code> as an indication that given fragment doesn't match target coordinates and + * match process should be continued + */ + @Nullable + public Boolean processTextFragment(int textFragmentIndex, int fragmentStartOffset) { + // Check if we are on the target line. + String wholeFragmentText = myTextFragments.get(textFragmentIndex); + if (myTargetY >= myLineStartY && myTargetY <= myLineEndY) { + int endOffset = findFragmentPartEndOffset(textFragmentIndex, fragmentStartOffset); + if (endOffset < 0) { + return null; + } + + String textToProcess = wholeFragmentText.substring(fragmentStartOffset, endOffset); + myDimensionCalculator.calculate(textToProcess, font, tmp); + int minimumWidth = myMinimumWidths.get(textFragmentIndex); + if (minimumWidth <= 0 || minimumWidth <= tmp.width || fragmentStartOffset > 0 || endOffset < wholeFragmentText.length()) { + // Reset forced minimum width if target fragment is wrapped or minimum width is less than the actual width. + minimumWidth = -1; + } + if (myTargetX < myLineX + Math.max(tmp.width, minimumWidth)) { + // We want to report 'no match' if target location points into space reserved for a minimum width but not actually occupied + // by fragment text. + return myTargetX < myLineX + tmp.width; + } + + if (endOffset == wholeFragmentText.length()) { + myLineX += Math.max(tmp.width, minimumWidth); + return null; + } + else { + // Target point is located beyond the fragment's part and there is a line break. + return false; + } + } + + int endOffset = findFragmentPartEndOffset(textFragmentIndex, fragmentStartOffset); + if (endOffset < 0) { + return null; + } + else if (endOffset == wholeFragmentText.length()) { + return null; + } + else { + onLineBreak(); + return processTextFragment(textFragmentIndex, endOffset); + } + } + + private int findFragmentPartEndOffset(int fragmentIndex, int fragmentPartStartOffset) { + String fragmentText = myTextFragments.get(fragmentIndex); + if (fragmentPartStartOffset >= fragmentText.length()) { + return -1; + } + TIntArrayList breakOffsets = myBreakOffsets.get(fragmentIndex); + if (breakOffsets == null || breakOffsets.isEmpty()) { + return fragmentText.length(); + } + if (fragmentPartStartOffset == 0) { + return breakOffsets.get(0); + } + for (int i = 0; i < breakOffsets.size(); i++) { + if (fragmentPartStartOffset == breakOffsets.get(i)) { + return i < breakOffsets.size() - 1 ? breakOffsets.get(i + 1) : fragmentText.length(); + } + } + return -1; + } + } +} diff --git a/android/testSrc/com/android/tools/idea/ui/WrapsAwareTextHelperTest.java b/android/testSrc/com/android/tools/idea/ui/WrapsAwareTextHelperTest.java new file mode 100644 index 00000000000..5fed513a963 --- /dev/null +++ b/android/testSrc/com/android/tools/idea/ui/WrapsAwareTextHelperTest.java @@ -0,0 +1,331 @@ +/* + * 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.ui; + +import com.intellij.ui.SimpleTextAttributes; +import gnu.trove.TIntArrayList; +import gnu.trove.TIntIntHashMap; +import gnu.trove.TIntObjectHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Before; +import org.junit.Test; + +import java.awt.*; +import java.util.*; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * @author Denis Zhdanov + * @since 10/09/14 + */ +public class WrapsAwareTextHelperTest { + + public static final Font DUMMY_FONT = new Font("", Font.PLAIN, 16); + private static final int SYMBOL_WIDTH = 10; + private static final int SYMBOL_HEIGHT = 10; + private static final String LINE_BREAK_MARKER; + static { + List<String> buffer = new ArrayList<String>(); + WrapsAwareTextHelper.appendLineBreak(buffer); + LINE_BREAK_MARKER = buffer.get(0); + } + + @NotNull WrapsAwareTextHelper.DimensionCalculator myDefaultDimensionCalculator = new WrapsAwareTextHelper.DimensionCalculator() { + @Override + public void calculate(@NotNull String inText, @NotNull Font inFont, @NotNull Dimension outDimension) { + outDimension.width = inText.length() * SYMBOL_WIDTH; + outDimension.height = SYMBOL_HEIGHT; + } + }; + + @NotNull private final TIntIntHashMap myMinimumWidths = new TIntIntHashMap(); + @NotNull private WrapsAwareTextHelper myCalculator; + + @Before + public void setUp() { + myCalculator = new WrapsAwareTextHelper(myDefaultDimensionCalculator); + myMinimumWidths.clear(); + } + + @Test + public void wrap_singleLine_singleToken_noWraps() { + doWrapTest(Collections.singletonList("abc"), Collections.singletonList("abc"), 3); + } + + @Test + public void wrap_singleLine_twoTokens_noWraps() { + doWrapTest(Arrays.asList("ab", "c"), Collections.singletonList("abc"), 3); + } + + @Test + public void wrap_singleLine_twoTokens_singleWrap() { + doWrapTest(Arrays.asList("ab", "cd"), Arrays.asList("ab", "cd"), 2); + doWrapTest(Arrays.asList("ab", "cd"), Arrays.asList("abc", "d"), 3); + } + + @Test + public void wrap_singleLine_manyTokens_manyWraps() { + doWrapTest(Arrays.asList("123", "4567", "8"), Arrays.asList("1234", "5678"), 4); + doWrapTest(Arrays.asList("123", "4567", "89"), Arrays.asList("1234", "5678", "9"), 4); + } + + @Test + public void wrap_singleLine_singleToken_singleWrap() { + doWrapTest(Collections.singletonList("abc"), Arrays.asList("ab", "c"), 2); + } + + @Test + public void wrap_singleLine_singleToken_twoWraps() { + doWrapTest(Collections.singletonList("12345678"), Arrays.asList("123", "456", "78"), 3); + doWrapTest(Collections.singletonList("123456789"), Arrays.asList("123", "456", "789"), 3); + } + + @Test + public void wrap_noWidthLimit() { + doWrapTest(Arrays.asList("abc"), Arrays.asList("abc"), 0); + doWrapTest(Arrays.asList("abc"), Arrays.asList("abc"), -1); + doWrapTest(Arrays.asList("abc"), Arrays.asList("abc"), Integer.MIN_VALUE); + } + + @Test + public void wrap_twoLines() { + doWrapTest(Arrays.asList("1234", LINE_BREAK_MARKER, "567"), Arrays.asList("123", "4", "567"), 3); + } + + @Test + public void map_singleLine_singleFragment() { + doMapTest(Arrays.asList("abc"), 3, 0, 0, 0); + doMapTest(Arrays.asList("abc"), 3, 0, 1, 0); + doMapTest(Arrays.asList("abc"), 3, 0, 2, 0); + doMapTest(Arrays.asList("abc"), 3, 0, 3, -1); + doMapTest(Arrays.asList("abc"), 3, 1, 1, -1); + } + + @Test + public void map_singleLine_multipleFragments_multipleWraps() { + doMapTest(Arrays.asList("abc", "def"), 2, 0, 0, 0); + doMapTest(Arrays.asList("abc", "def"), 2, 0, 1, 0); + doMapTest(Arrays.asList("abc", "def"), 2, 0, 2, -1); + doMapTest(Arrays.asList("abc", "def"), 2, 1, 0, 0); + doMapTest(Arrays.asList("abc", "def"), 2, 1, 2, -1); + doMapTest(Arrays.asList("abc", "def"), 2, 2, 0, 1); + doMapTest(Arrays.asList("abc", "def"), 2, 2, 1, 1); + doMapTest(Arrays.asList("abc", "def"), 2, 2, 2, -1); + doMapTest(Arrays.asList("abc", "def"), 2, 3, 0, -1); + doMapTest(Arrays.asList("abc", "def"), 2, 3, 1, -1); + doMapTest(Arrays.asList("abc", "def"), 2, 4, 1, -1); + } + + @Test + public void map_lineBreak() { + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 0, 0, 0); + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 0, 1, 0); + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 0, 2, -1); + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 1, 0, 2); + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 1, 1, 2); + doMapTest(Arrays.asList("ab", LINE_BREAK_MARKER, "de"), 3, 1, 2, -1); + } + + @Test + public void map_minimumWidth() { + myMinimumWidths.put(0, 3 * SYMBOL_WIDTH); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 0, 0); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 1, 0); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 2, -1); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 3, 1); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 4, 1); + doMapTest(Arrays.asList("ab", "cd"), 10, 0, 5, -1); + doMapTest(Arrays.asList("ab", "cd"), 10, 1, 0, -1); + } + + private void doWrapTest(@NotNull List<String> fragments, @NotNull List<String> expectedLines, int availableWidthInSymbols) { + // Verify that given data is consistent. + verifyTestData(fragments, expectedLines); + + // Calculate the data. + List<SimpleTextAttributes> textAttributes = Collections.nCopies(fragments.size(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + Dimension dimension = new Dimension(); + TIntObjectHashMap<TIntArrayList> breakOffsets = new TIntObjectHashMap<TIntArrayList>(); + TIntIntHashMap lineHeights = new TIntIntHashMap(); + int widthLimit = availableWidthInSymbols * SYMBOL_WIDTH; + myCalculator.wrap(fragments, textAttributes, DUMMY_FONT, myMinimumWidths, widthLimit, dimension, breakOffsets, lineHeights); + + // Check calculated dimension vs expected. + int expectedWidthInPixels = 0; + for (String line : expectedLines) { + expectedWidthInPixels = Math.max(expectedWidthInPixels, line.length() * SYMBOL_WIDTH); + } + assertEquals("Target text dimension width doesn't match", expectedWidthInPixels, dimension.width); + assertEquals("Target text dimension height doesn't match", expectedLines.size() * SYMBOL_HEIGHT, dimension.height); + + // Check line heights. + assertEquals("Target line heights don't match", buildExpectedLineHeights(expectedLines), lineHeights); + + // Check calculated text break offsets vs expected. + TIntObjectHashMap<TIntArrayList> expectedBreakOffsets = buildExpectedBreakOffsets(fragments, expectedLines); + assertEquals(expectedBreakOffsets, breakOffsets); + } + + @NotNull + private static TIntIntHashMap buildExpectedLineHeights(@NotNull List<String> expectedLines) { + TIntIntHashMap result = new TIntIntHashMap(); + for (int i = 0; i < expectedLines.size(); i++) { + result.put(i, SYMBOL_HEIGHT); + } + return result; + } + + @NotNull + private static TIntObjectHashMap<TIntArrayList> buildExpectedBreakOffsets(List<String> fragments, List<String> expectedLines) { + TIntObjectHashMap<TIntArrayList> expectedBreakOffsets = new TIntObjectHashMap<TIntArrayList>(); + int currentLineOffset = 0; + int fragmentOffset; + expectedLines = new ArrayList<String>(expectedLines); + for (int fragmentIndex = 0; fragmentIndex < fragments.size(); fragmentIndex++) { + String fragmentText = fragments.get(fragmentIndex); + fragmentOffset = 0; + if (LINE_BREAK_MARKER.equals(fragmentText)) { + currentLineOffset = 0; + expectedLines.remove(0); + continue; + } + while (true) { + String s = expectedLines.get(0); + if (s.length() - currentLineOffset < fragmentText.length() - fragmentOffset) { + TIntArrayList list = expectedBreakOffsets.get(fragmentIndex); + if (list == null) { + expectedBreakOffsets.put(fragmentIndex, list = new TIntArrayList()); + } + list.add(fragmentOffset += s.length() - currentLineOffset); + expectedLines.remove(0); + currentLineOffset = 0; + continue; + } + currentLineOffset += fragmentText.length() - fragmentOffset; + break; + } + } + return expectedBreakOffsets; + } + + private static void verifyTestData(@NotNull List<String> fragments, @NotNull List<String> expectedLines) { + SymbolIterator initialIterator = new SymbolIterator(fragments); + SymbolIterator expectedIterator = new SymbolIterator(expectedLines); + StringBuilder buffer = new StringBuilder(); + int offset = 0; + while (initialIterator.hasNext()) { + char c = initialIterator.next(); + buffer.append(c); + if (!expectedIterator.hasNext()) { + throw new IllegalArgumentException(String.format( + "Given input text has at least one more symbol than expected (at offset %d) %n Input:%n%s %n Expected:%n%s", offset, fragments, + expectedLines)); + } + char c1 = expectedIterator.next(); + if (c != c1) { + throw new IllegalArgumentException(String.format( + "Given input text mismatches given expected text: input text has symbol '%c' at offset %d but expected has '%c' (%s) %n " + + "Input:%n%s %n Expected:%n%s", + c, offset, c1, "..." + buffer.subSequence(Math.max(0, offset - 5), offset + 1), fragments, expectedLines + )); + } + offset++; + } + if (expectedIterator.hasNext()) { + throw new IllegalArgumentException(String.format( + "Given expected text has at least one more symbol than initial (at offset %d) %n Input:%n%s %n Expected:%n%s", + offset, fragments, expectedLines + )); + } + } + + private void doMapTest(@NotNull List<String> fragments, + int availableWidthInSymbols, + int targetLine, + int targetColumn, + int expectedFragmentIndex) + { + // Prepare the data. + List<SimpleTextAttributes> textAttributes = Collections.nCopies(fragments.size(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + Dimension dimension = new Dimension(); + TIntObjectHashMap<TIntArrayList> breakOffsets = new TIntObjectHashMap<TIntArrayList>(); + TIntIntHashMap lineHeights = new TIntIntHashMap(); + int widthLimit = availableWidthInSymbols * SYMBOL_WIDTH; + myCalculator.wrap(fragments, textAttributes, DUMMY_FONT, myMinimumWidths, widthLimit, dimension, breakOffsets, lineHeights); + + // Do test. + int actualFragmentIndex = myCalculator.mapFragment(fragments, + textAttributes, + myMinimumWidths, + breakOffsets, + lineHeights, + DUMMY_FONT, + targetColumn * SYMBOL_HEIGHT + SYMBOL_HEIGHT / 2, + targetLine * SYMBOL_HEIGHT + SYMBOL_HEIGHT / 2); + + if (expectedFragmentIndex < 0 ^ actualFragmentIndex < 0) { + fail(String.format("Mapped fragment index mismatch for the input data: fragments=%s, available width=%d, target line=%d, " + + "target column=%d, expected index=%d, actual index=%d", + fragments, availableWidthInSymbols, targetLine, targetColumn, expectedFragmentIndex, actualFragmentIndex)); + } + assertEquals("Target fragments index mismatch", expectedFragmentIndex, actualFragmentIndex); + } + + private static class SymbolIterator { + + @NotNull private final Iterator<String> myDelegate; + + @Nullable private String myCurrentString; + + private int myCurrentStringOffset; + + private SymbolIterator(@NotNull Iterable<String> strings) { + myDelegate = strings.iterator(); + } + + boolean hasNext() { + if (myCurrentString != null) { + return true; + } + if (!myDelegate.hasNext()) { + return false; + } + myCurrentString = myDelegate.next(); + if (LINE_BREAK_MARKER.equals(myCurrentString)) { + myCurrentString = null; + return hasNext(); + } + myCurrentStringOffset = 0; + return true; + } + + char next() { + if (!hasNext()) { + throw new IllegalStateException(); + } + assert myCurrentString != null; + char c = myCurrentString.charAt(myCurrentStringOffset++); + if (myCurrentStringOffset >= myCurrentString.length()) { + myCurrentString = null; + } + return c; + } + } +} |