package cn.yiiguxing.plugin.translate.ui;
import cn.yiiguxing.plugin.translate.*;
import cn.yiiguxing.plugin.translate.model.QueryResult;
import cn.yiiguxing.plugin.translate.ui.balloon.BalloonBuilder;
import cn.yiiguxing.plugin.translate.ui.balloon.BalloonImpl;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.RangeMarker;
import com.intellij.openapi.editor.ScrollType;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.JBMenuItem;
import com.intellij.openapi.ui.JBPopupMenu;
import com.intellij.openapi.ui.popup.Balloon;
import com.intellij.openapi.ui.popup.JBPopupFactory;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.wm.impl.IdeBackgroundUtil;
import com.intellij.ui.HyperlinkAdapter;
import com.intellij.ui.JBColor;
import com.intellij.ui.PopupMenuListenerAdapter;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.ui.components.JBPanel;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.ui.popup.PopupFactoryImpl;
import com.intellij.util.Alarm;
import com.intellij.util.Consumer;
import com.intellij.util.ui.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import javax.swing.event.HyperlinkEvent;
import javax.swing.event.PopupMenuEvent;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseEvent;
import java.util.Collections;
import java.util.List;
@SuppressWarnings("WeakerAccess")
public class TranslationBalloon implements TranslationContract.View {
private static final int MIN_BALLOON_WIDTH = JBUI.scale(200);
private static final int MIN_BALLOON_HEIGHT = JBUI.scale(50);
private static final int MAX_BALLOON_SIZE = JBUI.scale(600);
private static final JBInsets BORDER_INSETS = JBUI.insets(20, 20, 20, 20);
private final JBPanel mContentPanel;
private final GroupLayout mLayout;
private JPanel mProcessPanel;
private AnimatedIcon mProcessIcon;
private JLabel mQueryingLabel;
private Balloon mBalloon;
private RelativePoint mTargetLocation;
private boolean mInterceptDispose;
private boolean mDisposed;
@NotNull
private final Disposable mDisposable = new Disposable() {
@Override
public void dispose() {
onDispose();
}
};
@NotNull
private final TranslationContract.Presenter mTranslationPresenter;
@NotNull
private final Editor mEditor;
@Nullable
private final Project mProject;
private final RangeMarker mCaretRangeMarker;
public TranslationBalloon(@NotNull Editor editor, @NotNull RangeMarker caretRangeMarker) {
mEditor = editor;
mCaretRangeMarker = caretRangeMarker;
updateCaretPosition();
mContentPanel = new JBPanel<JBPanel>();
mLayout = new GroupLayout(mContentPanel);
mContentPanel.setOpaque(false);
mContentPanel.setLayout(mLayout);
mQueryingLabel.setForeground(new JBColor(new Color(0xFF4C4C4C), new Color(0xFFCDCDCD)));
mProcessPanel.setOpaque(false);
mLayout.setHorizontalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(mProcessPanel, MIN_BALLOON_WIDTH, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE));
mLayout.setVerticalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(mProcessPanel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE));
mContentPanel.add(mProcessPanel);
mProcessIcon.resume();
mTranslationPresenter = new TranslationPresenter(this);
mProject = editor.getProject();
if (mProject != null) {
Disposer.register(mProject, mDisposable);
}
}
private void createUIComponents() {
mProcessIcon = new ProcessIcon();
}
private void updateCaretPosition() {
if (mCaretRangeMarker.isValid()) {
int offset = Math.round((mCaretRangeMarker.getStartOffset() + mCaretRangeMarker.getEndOffset()) / 2f);
mEditor.putUserData(PopupFactoryImpl.ANCHOR_POPUP_POSITION, mEditor.offsetToVisualPosition(offset));
}
}
@NotNull
public Disposable getDisposable() {
return mDisposable;
}
private void onDispose() {
mDisposed = true;
mBalloon = null;
mCaretRangeMarker.dispose();
}
public void hide() {
if (!mDisposed) {
mDisposed = true;
if (mBalloon != null) {
mBalloon.hide();
}
Disposer.dispose(mDisposable);
}
}
@NotNull
private BalloonBuilder buildBalloon() {
return BalloonBuilder.builder(mContentPanel, null)
.setHideOnClickOutside(true)
.setShadow(true)
.setHideOnKeyOutside(true)
.setBlockClicksThroughBalloon(true)
.setBorderInsets(BORDER_INSETS);
}
public void showAndQuery(@NotNull String queryText) {
mBalloon = buildBalloon().setCloseButtonEnabled(false).createBalloon();
registerDisposer(mBalloon, true);
mEditor.getScrollingModel().scrollToCaret(ScrollType.MAKE_VISIBLE);
showBalloon(mBalloon);
mTranslationPresenter.query(queryText);
}
private void registerDisposer(@NotNull Balloon balloon, final boolean intercept) {
if (mProject != null) {
Disposer.register(mProject, balloon);
}
Disposer.register(balloon, new Disposable() {
@Override
public void dispose() {
if (mDisposed || (intercept && mInterceptDispose)) {
return;
}
Disposer.dispose(mDisposable);
mInterceptDispose = false;
}
});
}
private void showBalloon(@NotNull final Balloon balloon) {
final JBPopupFactory popupFactory = JBPopupFactory.getInstance();
balloon.show(new PositionTracker<Balloon>(mEditor.getContentComponent()) {
@Override
public RelativePoint recalculateLocation(Balloon object) {
if (mTargetLocation != null && !popupFactory.isBestPopupLocationVisible(mEditor)) {
return mTargetLocation;
}
updateCaretPosition();
final RelativePoint target = popupFactory.guessBestPopupLocation(mEditor);
Rectangle visibleArea = mEditor.getScrollingModel().getVisibleArea();
Point point = new Point(visibleArea.x, visibleArea.y);
SwingUtilities.convertPointToScreen(point, getComponent());
final Point screenPoint = target.getScreenPoint();
int y = screenPoint.y - point.y;
if (mTargetLocation != null && y + balloon.getPreferredSize().getHeight() > visibleArea.height) {
//FIXME 只是判断垂直方向,没有判断水平方向,但水平方向问题不是很大。
//FIXME 垂直方向上也只是判断Balloon显示在下方的情况,还是有些小问题。
return mTargetLocation;
}
mTargetLocation = new RelativePoint(new Point(screenPoint.x, screenPoint.y));
return mTargetLocation;
}
}, Balloon.Position.below);
}
@Override
public void showResult(@NotNull String query, @NotNull QueryResult result) {
if (mBalloon != null) {
if (mBalloon.isDisposed()) {
return;
}
mInterceptDispose = true;
mBalloon.hide(true);
} else {
return;
}
if (mDisposed) {
return;
}
final TranslationDialog translationDialog = TranslationUiManager.getInstance().getCurrentShowingDialog();
if (translationDialog != null) {
translationDialog.query(query);
}
mContentPanel.remove(0);
mProcessIcon.suspend();
mProcessIcon.dispose();
JTextPane resultText = new JTextPane() {
@Override
public void paint(Graphics g) {
// 还原设置图像背景后的图形上下文,使图像背景在JTextPane上失效。
super.paint(IdeBackgroundUtil.getOriginalGraphics(g));
}
};
resultText.setEditable(false);
resultText.setBackground(UIManager.getColor("Panel.background"));
setFont(resultText);
Styles.insertStylishResultText(resultText, result, new Styles.OnTextClickListener() {
@Override
public void onTextClick(@NotNull JTextPane textPane, @NotNull String text) {
showOnTranslationDialog(text);
}
});
resultText.setCaretPosition(0);
JBScrollPane scrollPane = new JBScrollPane(resultText);
scrollPane.setBorder(new JBEmptyBorder(0));
scrollPane.setVerticalScrollBar(scrollPane.createVerticalScrollBar());
scrollPane.setHorizontalScrollBar(scrollPane.createHorizontalScrollBar());
mLayout.setHorizontalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(scrollPane, MIN_BALLOON_WIDTH, GroupLayout.DEFAULT_SIZE, MAX_BALLOON_SIZE));
mLayout.setVerticalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(scrollPane, MIN_BALLOON_HEIGHT, GroupLayout.DEFAULT_SIZE, MAX_BALLOON_SIZE));
mContentPanel.add(scrollPane);
updateCaretPosition();
final BalloonImpl balloon = (BalloonImpl) buildBalloon().createBalloon();
RelativePoint showPoint = JBPopupFactory.getInstance().guessBestPopupLocation(mEditor);
createPinButton(balloon, showPoint, query);
registerDisposer(balloon, false);
showBalloon(balloon);
setPopupMenu(resultText);
mBalloon = balloon;
// 再刷新一下,尽可能地消除滚动条
revalidateBalloon(balloon);
}
private void setFont(JComponent component) {
Settings settings = Settings.getInstance();
if (settings.isOverrideFont()) {
final String fontFamily = settings.getPrimaryFontFamily();
if (!Utils.isEmptyOrBlankString(fontFamily)) {
component.setFont(JBUI.Fonts.create(fontFamily, 14));
return;
}
}
component.setFont(JBUI.Fonts.label(14));
}
@SuppressWarnings("SpellCheckingInspection")
private void revalidateBalloon(final BalloonImpl balloon) {
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
if (!balloon.isDisposed()) {
balloon.revalidate();
}
}
}, ModalityState.any());
final Alarm alarm = new Alarm(mDisposable);
alarm.addRequest(new Runnable() {
@Override
public void run() {
if (!balloon.isDisposed()) {
balloon.revalidate();
}
alarm.dispose();
}
}, 50, ModalityState.any());
}
private void showOnTranslationDialog(@Nullable String text) {
hide();
TranslationDialog dialog = TranslationUiManager.getInstance().showTranslationDialog(mEditor.getProject());
if (!Utils.isEmptyOrBlankString(text)) {
dialog.query(text);
}
}
private void setPopupMenu(final JTextPane textPane) {
final JBPopupMenu menu = new JBPopupMenu();
final JBMenuItem copy = new JBMenuItem("Copy", Icons.Copy);
copy.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
textPane.copy();
}
});
final JBMenuItem query = new JBMenuItem("Query", Icons.Translate);
query.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
String selectedText = textPane.getSelectedText();
showOnTranslationDialog(selectedText);
}
});
menu.add(copy);
menu.add(query);
menu.addPopupMenuListener(new PopupMenuListenerAdapter() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
boolean hasSelectedText = !Utils.isEmptyOrBlankString(textPane.getSelectedText());
copy.setEnabled(hasSelectedText);
query.setEnabled(hasSelectedText);
}
});
textPane.setComponentPopupMenu(menu);
}
private void createPinButton(final BalloonImpl balloon, final RelativePoint showPoint, final String query) {
balloon.setActionProvider(new BalloonImpl.ActionProvider() {
private BalloonImpl.ActionButton myPinButton;
private final Icon myIcon = Icons.Pin;
@NotNull
public List<BalloonImpl.ActionButton> createActions() {
myPinButton = balloon.new ActionButton(myIcon, myIcon, null,
new Consumer<MouseEvent>() {
@Override
public void consume(MouseEvent mouseEvent) {
if (mouseEvent.getClickCount() == 1) {
showOnTranslationDialog(query);
}
}
});
return Collections.singletonList(myPinButton);
}
public void layout(@NotNull Rectangle lpBounds) {
if (myPinButton.isVisible()) {
int iconWidth = myIcon.getIconWidth();
int iconHeight = myIcon.getIconHeight();
int margin = JBUI.scale(3);
int x = lpBounds.x + lpBounds.width - iconWidth - margin;
int y = lpBounds.y + margin;
Rectangle rectangle = new Rectangle(x, y, iconWidth, iconHeight);
Insets border = balloon.getShadowBorderInsets();
rectangle.x -= border.left;
// FIXME 由于现在的Balloon是可以移动的,所以showPoint不再那么准确了,可以会使得PinButton显示位置不对。
RelativePoint location = mTargetLocation != null ? mTargetLocation : showPoint;
int showX = location.getPoint().x;
int showY = location.getPoint().y;
// 误差
int offset = JBUI.scale(1);
boolean atRight = showX <= lpBounds.x + offset;
boolean atLeft = showX >= (lpBounds.x + lpBounds.width - offset);
boolean below = lpBounds.y >= showY;
boolean above = (lpBounds.y + lpBounds.height) <= showY;
if (atRight || atLeft || below || above) {
rectangle.y += border.top;
}
myPinButton.setBounds(rectangle);
}
}
});
}
@NotNull
private static HTMLEditorKit getErrorHTMLKit() {
HTMLEditorKit kit = UIUtil.getHTMLEditorKit();
JBFont font = JBUI.Fonts.label(16);
StyleSheet styleSheet = kit.getStyleSheet();
styleSheet.addRule(String.format("body {color:#FF3333; font-family: %s;font-size: %s; text-align: center;}",
font.getFamily(), font.getSize()));
styleSheet.addRule("a {color:#FF0000;}");
return kit;
}
@Override
public void showError(@NotNull String query, @NotNull String error) {
if (mBalloon == null || mBalloon.isDisposed())
return;
mContentPanel.remove(0);
mProcessIcon.suspend();
mProcessIcon.dispose();
JEditorPane text = new JEditorPane();
text.setContentType("text/html");
text.setEditorKit(getErrorHTMLKit());
text.setEditable(false);
text.setOpaque(false);
text.addHyperlinkListener(new HyperlinkAdapter() {
@Override
protected void hyperlinkActivated(HyperlinkEvent hyperlinkEvent) {
if (Constants.HTML_DESCRIPTION_SETTINGS.equals(hyperlinkEvent.getDescription())) {
hide();
TranslationOptionsConfigurable.showSettingsDialog(mProject);
}
}
});
text.setText(error);
mLayout.setHorizontalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(text, MIN_BALLOON_WIDTH, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE));
mLayout.setVerticalGroup(mLayout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(text, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE));
mContentPanel.add(text);
mBalloon.revalidate();
}
}