summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android/src/com/android/tools/idea/gradle/invoker/messages/GradleBuildTreeViewPanel.java18
-rw-r--r--android/src/com/android/tools/idea/gradle/invoker/messages/MessageTreeRenderer.java6
-rw-r--r--android/src/com/android/tools/idea/ui/MultilineColoredTreeCellRenderer.java385
-rw-r--r--android/src/com/android/tools/idea/ui/WrapAwareColoredComponent.java1155
-rw-r--r--android/src/com/android/tools/idea/ui/WrapAwareLinkMouseListenerBase.java67
-rw-r--r--android/src/com/android/tools/idea/ui/WrapAwareTreeNodePartListener.java65
-rw-r--r--android/src/com/android/tools/idea/ui/WrapsAwareTextHelper.java458
-rw-r--r--android/testSrc/com/android/tools/idea/ui/WrapsAwareTextHelperTest.java331
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:
+ *
+ * | |
+ * | |&lt;-- 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;
+ }
+ }
+}