blob: a546c30cc50c9b6b02134c9fee16f443eb301762 [file] [log] [blame]
/*
* Copyright (C) 2015 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.gradle.editor.ui;
import com.android.SdkConstants;
import com.android.tools.idea.gradle.editor.entity.GradleEditorSourceBinding;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.collect.*;
import com.intellij.ide.ui.UISettings;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.editor.RangeMarker;
import com.intellij.openapi.editor.colors.EditorColors;
import com.intellij.openapi.editor.colors.EditorColorsManager;
import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.openapi.fileEditor.OpenFileDescriptor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.popup.Balloon;
import com.intellij.openapi.ui.popup.BalloonBuilder;
import com.intellij.openapi.ui.popup.JBPopupFactory;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.vfs.VfsUtilCore;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.IdeBorderFactory;
import com.intellij.ui.JBColor;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.ui.components.JBLabel;
import com.intellij.ui.components.JBPanel;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.text.CharArrayUtil;
import com.intellij.util.ui.GridBag;
import com.intellij.util.ui.UIUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionAdapter;
import java.awt.image.BufferedImage;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/**
* There is a possible case that there is more than one {@link GradleEditorSourceBinding source binding} for particular value, e.g.:
* <pre>
* ext.COMPILE_SDK_VERSION = 1
* if (System.getenv("my-custom-environment)) {
* COMPILE_SDK_VERSION = 2
* }
* </pre>
* Here there are two bindings - <code>'1'</code> and <code>'2'</code>, so, we should somehow indicate at UI level that there
* is no single value and provide convenient access to the registered bindings.
* <p/>
* Current control solves that task.
*/
public class ReferencedValuesGradleEditorComponent extends JBPanel {
private static final Function<GradleEditorSourceBinding, VirtualFile> GROUPER = new Function<GradleEditorSourceBinding, VirtualFile>() {
@Override
public VirtualFile apply(GradleEditorSourceBinding input) {
return input.getFile();
}
};
private static final Comparator<VirtualFile> FILES_COMPARATOR = new Comparator<VirtualFile>() {
@Override
public int compare(VirtualFile f1, VirtualFile f2) {
if (f1.equals(f2)) {
return 0;
}
VirtualFile d1 = f1.isDirectory() ? f1 : f1.getParent();
VirtualFile d2 = f2.isDirectory() ? f2 : f2.getParent();
if (d1.equals(d2)) {
// Just use lexicographic order for files from the same directory.
return f1.getName().compareTo(f2.getName());
}
// The general idea is to prefer files located more close to the file system root to files located lower.
if (VfsUtilCore.isAncestor(d1, d2, false)) {
return -1;
}
else if (VfsUtilCore.isAncestor(d2, d1, false)) {
return 1;
}
for (VirtualFile p1 = d1.getParent(), p2 = d2.getParent(); ; p1 = p1.getParent(), p2 = p2.getParent()) {
if (p1 == null && p2 == null) {
return 0;
}
else if (p1 == null) {
return -1;
}
else if (p2 == null) {
return 1;
}
}
}
};
private static final Comparator<RangeMarker> RANGE_COMPARATOR = new Comparator<RangeMarker>() {
@Override
public int compare(RangeMarker rm1, RangeMarker rm2) {
if (rm1.getStartOffset() < rm2.getStartOffset()) {
return -1;
}
else if (rm2.getStartOffset() < rm1.getStartOffset()) {
return 1;
}
else if (rm1.getEndOffset() < rm2.getEndOffset()) {
return -1;
}
else if (rm2.getEndOffset() < rm1.getEndOffset()) {
return 1;
}
return 0;
}
};
/** Holds source binding grouped by file */
private final Map<String, List<RangeMarker>> mySourceBindings = Maps.newLinkedHashMap();
private final Map<String, VirtualFile> myFilesByName = Maps.newHashMap();
@Nullable private WeakReference<Project> myProjectRef;
public ReferencedValuesGradleEditorComponent() {
super(new GridBagLayout());
final JBLabel label = new JBLabel("<~>");
label.setCursor(new Cursor(Cursor.HAND_CURSOR));
setBackground(GradleEditorUiConstants.BACKGROUND_COLOR);
TextAttributes attributes = EditorColorsManager.getInstance().getGlobalScheme().getAttributes(EditorColors.FOLDED_TEXT_ATTRIBUTES);
if (attributes != null) {
Color color = attributes.getForegroundColor();
if (color != null) {
label.setForeground(color);
}
}
add(label, new GridBag());
label.addMouseListener(new MouseAdapter() {
@Override
public void mouseReleased(MouseEvent e) {
WeakReference<Project> projectRef = myProjectRef;
if (projectRef == null) {
return;
}
Project project = projectRef.get();
if (project == null) {
return;
}
final Ref<Balloon> balloonRef = new Ref<Balloon>();
Content content = new Content(project, new Runnable() {
@Override
public void run() {
Balloon balloon = balloonRef.get();
if (balloon != null && !balloon.isDisposed()) {
Disposer.dispose(balloon);
balloonRef.set(null);
}
}
});
BalloonBuilder builder = JBPopupFactory.getInstance().createBalloonBuilder(content).setDisposable(project)
.setShowCallout(false).setAnimationCycle(GradleEditorUiConstants.ANIMATION_TIME_MILLIS).setFillColor(JBColor.border());
Balloon balloon = builder.createBalloon();
balloonRef.set(balloon);
balloon.show(new RelativePoint(label, new Point(label.getWidth() / 2, label.getHeight())), Balloon.Position.atRight);
}
});
}
public void bind(@NotNull Project project, @NotNull List<GradleEditorSourceBinding> sourceBindings) {
myProjectRef = new WeakReference<Project>(project);
ImmutableListMultimap<VirtualFile, GradleEditorSourceBinding> byFile = Multimaps.index(sourceBindings, GROUPER);
List<VirtualFile> orderedFiles = Lists.newArrayList(byFile.keySet());
ContainerUtil.sort(orderedFiles, FILES_COMPARATOR);
for (VirtualFile file : orderedFiles) {
ImmutableList<GradleEditorSourceBinding> list = byFile.get(file);
List<RangeMarker> rangeMarkers = Lists.newArrayList();
for (GradleEditorSourceBinding descriptor : list) {
rangeMarkers.add(descriptor.getRangeMarker());
}
if (!rangeMarkers.isEmpty()) {
ContainerUtil.sort(rangeMarkers, RANGE_COMPARATOR);
String name = getRepresentativeName(project, file);
mySourceBindings.put(name, rangeMarkers);
myFilesByName.put(name, file);
}
}
}
/**
* @param project current ide project
* @param file target file
* @return convenient user-readable name for the given file, e.g. there is a possible case that we have a multi-project
* and multiple {@code build.gradle} files are located there. We want to show names like {@code build.gradle},
* {@code :app:build.gradle} instead of full paths then
*/
@NotNull
private static String getRepresentativeName(@NotNull Project project, @NotNull VirtualFile file) {
VirtualFile projectBaseDir = project.getBaseDir();
if (!VfsUtilCore.isAncestor(projectBaseDir, file, false)) {
return file.getPresentableName();
}
List<String> pathEntries = Lists.newArrayList();
for (VirtualFile f = file.getParent(); !projectBaseDir.equals(f); f = f.getParent()) {
pathEntries.add(f.getPresentableName());
}
if (pathEntries.isEmpty()) {
return file.getPresentableName();
}
Collections.reverse(pathEntries);
String sep = SdkConstants.GRADLE_PATH_SEPARATOR;
return sep + Joiner.on(sep).join(pathEntries) + sep + file.getPresentableName();
}
/**
* Creates an image which represents editor's text from the region identified by the given range marker.
*
* @param editor an editor which serves as a renderer for the target text
* @param marker range marker that points to the target text region
* @param minWidthPx width in pixels to constraint resulting image from below
* @return an image which represents target text fragment
*/
@NotNull
private static BufferedImage getContentToShow(@NotNull Editor editor, @NotNull RangeMarker marker, int minWidthPx) {
int maxWidth = Toolkit.getDefaultToolkit().getScreenSize().width * 4 / 5;
Document document = editor.getDocument();
int startLine = document.getLineNumber(marker.getStartOffset());
int endLine = document.getLineNumber(marker.getEndOffset());
int minStartX = Integer.MAX_VALUE;
int maxEndX = 0;
CharSequence text = document.getCharsSequence();
// Calculate desired text dimensions.
for (int line = startLine; line <= endLine; line++) {
int startOffsetToUse = CharArrayUtil.shiftForward(text, document.getLineStartOffset(line), document.getLineEndOffset(line), " \t");
int endOffsetToUse = CharArrayUtil.shiftBackward(text, document.getLineStartOffset(line), document.getLineEndOffset(line), " \t");
minStartX = Math.min(minStartX, offsetToXY(editor, startOffsetToUse).x);
maxEndX = Math.max(maxEndX, offsetToXY(editor, endOffsetToUse).x);
}
// Calculate text dimensions taking into consideration min/max/desired width
int desiredWidth = maxEndX - minStartX;
final int xStart;
final int xEnd;
if (desiredWidth > maxWidth) {
int xShift = (desiredWidth - maxWidth) / 2;
xStart = offsetToXY(editor, minStartX).x + xShift;
xEnd = xStart + maxWidth;
}
else if (desiredWidth < minWidthPx) {
int xShift = (minWidthPx - desiredWidth) / 2;
xStart = offsetToXY(editor, minStartX).x - xShift;
xEnd = xStart + minWidthPx;
}
else {
xStart = minStartX;
xEnd = maxEndX;
}
int lineHeight = editor.getLineHeight();
int yStart = offsetToXY(editor, marker.getStartOffset()).y;
int yEnd = yStart + lineHeight + lineHeight * (endLine - startLine);
int width = xEnd - xStart;
int height = yEnd - yStart;
// Ask the editor to render target text.
JScrollPane scrollPane = UIUtil.findComponentOfType(editor.getComponent(), JScrollPane.class);
BufferedImage image = UIUtil.createImage(width, height, BufferedImage.TYPE_INT_ARGB);
if (scrollPane != null) {
Component editorComponent = scrollPane.getViewport().getView();
editorComponent.setSize(Integer.MAX_VALUE, Integer.MAX_VALUE);
Graphics2D graphics = image.createGraphics();
UISettings.setupAntialiasing(graphics);
graphics.translate(-xStart, -yStart);
graphics.setClip(xStart, yStart, width, height);
editorComponent.paint(graphics);
graphics.dispose();
}
return image;
}
@NotNull
private static Point offsetToXY(@NotNull Editor editor, int offset) {
return editor.visualPositionToXY(editor.offsetToVisualPosition(offset));
}
private class Content extends JBPanel {
private static final String FILE_KEY = "__FILE";
private static final String MARKER_KEY = "__MARKER";
private final List<JComponent> myTextFragmentPanels = Lists.newArrayList();
@NotNull private final Runnable myCloseCallback;
@Nullable private JComponent myTextFragmentPanelUnderMouse;
/**
* Constructs new <code>ReferencedValuesGradleEditorComponent</code> object.
*
* @param project current ide project
* @param closeCallback callback to notify that current content should be closed
*/
Content(@NotNull final Project project, @NotNull Runnable closeCallback) {
super(new GridBagLayout());
myCloseCallback = closeCallback;
setBackground(GradleEditorUiConstants.BACKGROUND_COLOR);
int gap = 8;
GridBag constraints = new GridBag().fillCellHorizontally().weightx(1).coverLine().insets(gap, gap, gap, gap);
EditorFactory editorFactory = EditorFactory.getInstance();
int maxTitleWidthPx = 0;
for (String s : mySourceBindings.keySet()) {
maxTitleWidthPx = Math.max(maxTitleWidthPx, getFontMetrics(UIUtil.getTitledBorderFont()).stringWidth(s));
}
for (Map.Entry<String, List<RangeMarker>> entry : mySourceBindings.entrySet()) {
JBPanel titledPanel = new JBPanel(new GridBagLayout());
titledPanel.setBackground(GradleEditorUiConstants.BACKGROUND_COLOR);
titledPanel.setBorder(IdeBorderFactory.createTitledBorder(entry.getKey()));
boolean hasContent = false;
Editor editor = null;
for (RangeMarker marker : entry.getValue()) {
if (!marker.isValid()) {
continue;
}
if (editor == null) {
editor = editorFactory.createEditor(marker.getDocument(), project);
}
JBPanel fragmentPanel = new JBPanel(new GridBagLayout());
fragmentPanel.putClientProperty(FILE_KEY, entry.getKey());
fragmentPanel.putClientProperty(MARKER_KEY, marker);
fragmentPanel.setForeground(GradleEditorUiConstants.BACKGROUND_COLOR);
fragmentPanel.setBackground(GradleEditorUiConstants.BACKGROUND_COLOR);
fragmentPanel.setBorder(BorderFactory.createLoweredBevelBorder());
final BufferedImage contentToShow = getContentToShow(editor, marker, maxTitleWidthPx);
fragmentPanel.add(new JLabel(new ImageIcon(contentToShow)), constraints);
hasContent = true;
titledPanel.add(fragmentPanel, constraints);
myTextFragmentPanels.add(fragmentPanel);
}
if (editor != null) {
editorFactory.releaseEditor(editor);
}
if (hasContent) {
add(titledPanel, constraints);
}
}
addMouseMotionListener(new MouseMotionAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
if (myTextFragmentPanelUnderMouse != null && !isInside(e, myTextFragmentPanelUnderMouse)) {
myTextFragmentPanelUnderMouse.setBorder(BorderFactory.createLoweredBevelBorder());
myTextFragmentPanelUnderMouse = null;
}
//noinspection NullableProblems
for (JComponent panel : myTextFragmentPanels) {
if (isInside(e, panel)) {
myTextFragmentPanelUnderMouse = panel;
panel.setBorder(BorderFactory.createRaisedBevelBorder());
}
}
}
});
addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (myTextFragmentPanelUnderMouse != null && isInside(e, myTextFragmentPanelUnderMouse)) {
myTextFragmentPanelUnderMouse.setBorder(BorderFactory.createLoweredBevelBorder());
}
}
@Override
public void mouseClicked(MouseEvent e) {
if (myTextFragmentPanelUnderMouse == null || !isInside(e, myTextFragmentPanelUnderMouse)) {
return;
}
Object fileName = myTextFragmentPanelUnderMouse.getClientProperty(FILE_KEY);
if (!(fileName instanceof String)) {
return;
}
VirtualFile file = myFilesByName.get(fileName.toString());
if (file == null) {
return;
}
Object m = myTextFragmentPanelUnderMouse.getClientProperty(MARKER_KEY);
if (!(m instanceof RangeMarker)) {
return;
}
RangeMarker marker = (RangeMarker)m;
if (!marker.isValid()) {
return;
}
OpenFileDescriptor descriptor = new OpenFileDescriptor(project, file, marker.getStartOffset());
if (descriptor.canNavigate()) {
descriptor.navigate(true);
myCloseCallback.run();
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (myTextFragmentPanelUnderMouse != null && isInside(e, myTextFragmentPanelUnderMouse)) {
myTextFragmentPanelUnderMouse.setBorder(BorderFactory.createRaisedBevelBorder());
}
}
});
}
private boolean isInside(@NotNull MouseEvent e, @NotNull JComponent component) {
return component.contains(SwingUtilities.convertPoint(this, e.getPoint(), component));
}
}
}