/* * 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; } } }