/*
* Copyright (C) 2013-2015, VistaTEC or third-party contributors as indicated
* by the @author tags or express copyright attribution statements applied by
* the authors. All third-party contributions are distributed under license by
* VistaTEC.
*
* This file is part of Ocelot.
*
* Ocelot is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Ocelot is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, write to:
*
* Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor
* Boston, MA 02110-1301
* USA
*
* Also, see the full LGPL text here: <http://www.gnu.org/copyleft/lesser.html>
*/
package com.vistatec.ocelot.segment.view;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.ComponentOrientation;
import java.awt.Container;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.HierarchyBoundsListener;
import java.awt.event.HierarchyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.swing.DropMode;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JTextPane;
import javax.swing.TransferHandler;
import javax.swing.border.EmptyBorder;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.text.AbstractDocument;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultStyledDocument;
import javax.swing.text.DocumentFilter;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import javax.swing.text.StyledDocument;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.vistatec.ocelot.Ocelot;
import com.vistatec.ocelot.segment.model.CodeAtom;
import com.vistatec.ocelot.segment.model.PositionAtom;
import com.vistatec.ocelot.segment.model.SegmentAtom;
import com.vistatec.ocelot.segment.model.SegmentVariant;
/**
* Representation of source/target segment text in segment table view.
* Handles the style of the text with Inline tags and the link between
* the editor behavior and the underlying data structure.
*/
public class SegmentTextCell extends JTextPane {
private static final long serialVersionUID = 1L;
private static Logger LOG = LoggerFactory.getLogger(SegmentTextCell.class);
public static final String tagStyle = "tag", regularStyle = "regular",
insertStyle = "insert", deleteStyle = "delete", enrichedStyle = "enriched", highlightStyle="highlight", currHighlightStyle="currHighlight";
private int row = -1;
private SegmentVariant vOrig;
private SegmentVariant v;
private boolean raw;
private JFrame menuFrame;
// Shared styles table
private static final StyleContext styles = new StyleContext();
static {
Style style = StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE);
Style regular = styles.addStyle(regularStyle, style);
Style s = styles.addStyle(tagStyle, regular);
StyleConstants.setBackground(s, Color.LIGHT_GRAY);
Style insert = styles.addStyle(insertStyle, s);
StyleConstants.setForeground(insert, Color.BLUE);
StyleConstants.setUnderline(insert, true);
Style delete = styles.addStyle(deleteStyle, insert);
StyleConstants.setForeground(delete, Color.RED);
StyleConstants.setStrikeThrough(delete, true);
StyleConstants.setUnderline(delete, false);
Style highlight = styles.addStyle(highlightStyle, regular);
StyleConstants.setBackground(highlight, Color.yellow);
Style currHighlight = styles.addStyle(currHighlightStyle, regular);
StyleConstants.setBackground(currHighlight, Color.green);
}
/**
* Create a dummy cell for the purposes of cell sizing. This cell
* doesn't contain the style information and isn't linked to any of
* the control logic.
* @return dummy cell
*/
public static SegmentTextCell createDummyCell() {
return new SegmentTextCell();
}
/**
* Create an empty cell for the purpose of holding live content. This
* cell contains style information and is linked to the document.
* @return real cell
*/
public static SegmentTextCell createCell() {
return new SegmentTextCell(styles);
}
/**
* Create an empty cell holding the specified content. This
* cell contains style information and is linked to the document.
* @param v
* @param raw
* @param isBidi whether the cell contains bidi content
* @return
*/
public static SegmentTextCell createCell(int row, SegmentVariant v, boolean raw, boolean isBidi) {
return new SegmentTextCell(row, v, raw, isBidi);
}
private SegmentTextCell(StyleContext styleContext) {
super(new DefaultStyledDocument(styleContext));
setEditController();
addCaretListener(new TagSelectingCaretListener());
setTransferHandler(new TagAwareTransferHandler());
setDragEnabled(true);
setDropMode(DropMode.INSERT);
addMouseListener(new ContextMenuListener());
}
private SegmentTextCell() {
super();
}
private SegmentTextCell(int row, SegmentVariant v, boolean raw, boolean isBidi) {
this(styles);
setVariant(row, v, raw);
setBidi(isBidi);
}
public void setBidi(boolean isBidi) {
if (isBidi) {
setComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);
}
}
/**
* A caret listener that detects selections that encompass
* only part of tags and automatically expand the selection
* to include full tags. This produces cascading CaretUpdate
* events, but the cycle should stop after a single additional
* update.
*/
class TagSelectingCaretListener implements CaretListener {
@Override
public void caretUpdate(CaretEvent e) {
if (e.getDot() != e.getMark()) {
int origStart = Math.min(e.getDot(), e.getMark());
int origEnd = Math.max(e.getDot(), e.getMark());
int start = v.findSelectionStart(origStart);
int end = v.findSelectionEnd(origEnd);
if (start != origStart) {
setSelectionStart(start);
}
if (end != origEnd) {
setSelectionEnd(end);
}
}
}
}
public final void setEditController() {
StyledDocument styledDoc = getStyledDocument();
if (styledDoc instanceof AbstractDocument) {
AbstractDocument doc = (AbstractDocument)styledDoc;
doc.setDocumentFilter(new SegmentFilter());
}
}
public final void setDisplayCategories() {
Style style = StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE);
StyledDocument styleDoc = this.getStyledDocument();
Style regular = styleDoc.addStyle(regularStyle, style);
Style highlight = styleDoc.addStyle(highlightStyle, regular);
StyleConstants.setBackground(highlight, Color.yellow);
Style currHighlight = styleDoc.addStyle(currHighlightStyle, regular);
StyleConstants.setBackground(currHighlight, Color.green);
Style s = styleDoc.addStyle(tagStyle, regular);
StyleConstants.setBackground(s, Color.LIGHT_GRAY);
Style insert = styleDoc.addStyle(insertStyle, s);
StyleConstants.setForeground(insert, Color.BLUE);
StyleConstants.setUnderline(insert, true);
Style delete = styleDoc.addStyle(deleteStyle, insert);
StyleConstants.setForeground(delete, Color.RED);
StyleConstants.setStrikeThrough(delete, true);
StyleConstants.setUnderline(delete, false);
Style enriched = styleDoc.addStyle(enrichedStyle, regular);
StyleConstants.setForeground(enriched, Color.BLUE);
StyleConstants.setUnderline(enriched, true);
}
public void setTextPane(List<String> styledText) {
StyledDocument doc = this.getStyledDocument();
try {
for (int i = 0; i < styledText.size(); i += 2) {
doc.insertString(doc.getLength(), styledText.get(i),
doc.getStyle(styledText.get(i + 1)));
}
} catch (BadLocationException ex) {
LOG.error("Error rendering text", ex);
}
}
public SegmentVariant getVariant() {
return this.v;
}
public final void setVariant(int row, SegmentVariant v, boolean raw) {
this.row = row;
this.v = v;
this.vOrig = v.createCopy();
this.raw = raw;
syncModelToView();
}
private void syncModelToView() {
SegmentVariant tmp = v;
try {
// We temporarily set v to null here to get around the
// SegmentFilter, which will prevent us from clearing the text if
// there are tags.
v = null;
StyledDocument doc = getStyledDocument();
doc.remove(0, doc.getLength());
} catch (BadLocationException e) {
LOG.debug("", e);
} finally {
v = tmp;
}
if (v != null) {
setTextPane(v.getStyleData(raw));
}
else {
setTextPane(new ArrayList<String>());
}
}
public void setTargetDiff(List<String> targetDiff) {
setTextPane(targetDiff);
}
@Override
public String getToolTipText(MouseEvent event) {
Point p = event.getPoint();
int offset = viewToModel(p);
if (v != null && v.containsTag(offset, 0)) {
SegmentAtom atom = v.getAtomAt(offset);
if (atom instanceof CodeAtom) {
return ((CodeAtom) atom).getVerboseData();
}
}
return super.getToolTipText(event);
}
/**
* Handles edit behavior in segment text cell.
*/
public class SegmentFilter extends DocumentFilter {
// This is also called when initially populating the table,
// as swing will try to "remove" the old contents.
@Override
public void remove(FilterBypass fb, int offset, int length)
throws BadLocationException {
// When composing text (CJK, etc., input) allow all edits to the
// view only.
if (getStyledDocument().getCharacterElement(offset).getAttributes()
.isDefined(StyleConstants.ComposedTextAttribute)) {
fb.remove(offset, length);
return;
}
if (v != null) {
// Allow atomic tag deletions
if (v.containsTag(offset, length)) {
int start = v.findSelectionStart(offset);
int end = v.findSelectionEnd(offset + length);
v.clearSelection(start, end);
super.remove(fb, start, end - start);
} else {
// Remove from cell editor
super.remove(fb, offset, length);
// Remove from underlying segment structure
deleteChars(offset, length);
}
}
else {
// TODO: why does this correct the spacing issue?
super.remove(fb, offset, length);
}
}
@Override
public void replace(FilterBypass fb, int offset, int length, String str,
AttributeSet a) throws BadLocationException {
// When composing text (CJK, etc., input) allow all edits to the
// view only.
if (a.isDefined(StyleConstants.ComposedTextAttribute)) {
fb.replace(offset, length, str, a);
return;
}
if (length > 0) {
if (v.containsTag(offset, length)) {
int start = v.findSelectionStart(offset);
int end = v.findSelectionEnd(offset + length);
v.clearSelection(start, end);
v.modifyChars(offset, 0, str);
super.replace(fb, start, end - start, str, a);
} else {
// Remove from cell editor
super.replace(fb, offset, length, str, a);
// Remove from underlying segment structure
v.modifyChars(offset, length, str);
}
} else {
if (v.canInsertAt(offset)) {
// Insert string into cell editor.
super.replace(fb, offset, length, str, a);
insertChars(str, offset);
}
}
}
public void deleteChars(int offset, int charsToRemove) {
v.modifyChars(offset, charsToRemove, null);
}
public void insertChars(String insertText, int offset) {
v.modifyChars(offset, 0, insertText);
}
}
static class TagAwareTransferHandler extends TransferHandler {
private static final long serialVersionUID = 1L;
private boolean shouldRemove;
@Override
public int getSourceActions(JComponent c) {
return TransferHandler.COPY_OR_MOVE;
}
@Override
protected Transferable createTransferable(JComponent c) {
SegmentTextCell cell = (SegmentTextCell) c;
shouldRemove = true;
return new SegmentVariantTransferable("" + cell.row, cell.v, cell.getSelectionStart(),
cell.getSelectionEnd());
}
@Override
protected void exportDone(JComponent source, Transferable data, int action) {
SegmentTextCell cell = (SegmentTextCell) source;
if (shouldRemove && action == TransferHandler.MOVE) {
// Only clear the original selection here if we didn't
// already handle it in importData().
((SegmentVariantTransferable) data).removeText();
cell.syncModelToView();
}
}
@Override
public boolean canImport(TransferSupport support) {
return support.isDataFlavorSupported(SELECTION_FLAVOR)
|| support.isDataFlavorSupported(DataFlavor.stringFlavor);
}
@Override
public boolean importData(TransferSupport support) {
if (!canImport(support)) {
return false;
}
SegmentTextCell cell = (SegmentTextCell) support.getComponent();
if (support.isDataFlavorSupported(SELECTION_FLAVOR)) {
return importSegmentVariantSelection(cell, support);
} else if (support.isDataFlavorSupported(DataFlavor.stringFlavor)) {
return importString(cell, support);
}
return false;
}
private boolean importSegmentVariantSelection(SegmentTextCell cell, TransferSupport support) {
try {
Transferable trfr = support.getTransferable();
SegmentVariantSelection sel = (SegmentVariantSelection) trfr.getTransferData(SELECTION_FLAVOR);
// Check to make sure we're pasting from the same row and the
// same variant type.
if (sel.getId().equals("" + cell.row) && cell.v.getClass().equals(sel.getVariant().getClass())) {
int start, end;
if (support.isDrop()) {
Point p = support.getDropLocation().getDropPoint();
start = end = cell.viewToModel(p);
} else {
start = cell.getSelectionStart();
end = cell.getSelectionEnd();
}
// Check to make sure we're not pasting into any tags
if (cell.v.containsTag(start, end - start)) {
return false;
}
boolean isDragMove = support.isDrop() && support.getDropAction() == TransferHandler.MOVE;
if (isDragMove && start > sel.getSelectionStart() && start < sel.getSelectionEnd()) {
// Don't remove source text if we are dropping within
// the initial selection.
shouldRemove = false;
}
cell.v.replaceSelection(start, end, sel);
cell.syncModelToView();
return true;
} else if (support.isDataFlavorSupported(DataFlavor.stringFlavor)) {
// We're not pasting from the same row so it's not safe to
// import tags. We can import plain text instead.
return importString(cell, support);
}
} catch (UnsupportedFlavorException | IOException e) {
LOG.info("", e);
}
return false;
}
private boolean importString(SegmentTextCell cell, TransferSupport support) {
try {
Transferable trfr = support.getTransferable();
String str = trfr.getTransferData(DataFlavor.stringFlavor).toString();
int start, end;
if (support.isDrop()) {
Point p = support.getDropLocation().getDropPoint();
start = end = cell.viewToModel(p);
} else {
start = cell.getSelectionStart();
end = cell.getSelectionEnd();
}
// Check to make sure we're not pasting into any tags
if (cell.v.containsTag(start, end - start)) {
return false;
}
// Rely on SegmentFilter to do the dirty work.
cell.replaceSelection(str);
return true;
} catch (UnsupportedFlavorException | IOException e) {
LOG.debug("", e);
}
return false;
}
}
static final DataFlavor SELECTION_FLAVOR = new DataFlavor(SegmentVariantSelection.class,
SegmentVariantSelection.class.getSimpleName());
static class SegmentVariantTransferable implements Transferable {
private static final DataFlavor[] FLAVORS = { SELECTION_FLAVOR, DataFlavor.stringFlavor };
private final SegmentVariant source;
private final PositionAtom start;
private final PositionAtom end;
private final SegmentVariantSelection selection;
public SegmentVariantTransferable(String id, SegmentVariant source, int start, int end) {
this.source = source;
this.start = source.createPosition(start);
this.end = source.createPosition(end);
this.selection = new SegmentVariantSelection(id, source.createCopy(), start, end);
}
void removeText() {
source.clearSelection(start.getPosition(), end.getPosition());
}
@Override
public DataFlavor[] getTransferDataFlavors() {
return FLAVORS;
}
@Override
public boolean isDataFlavorSupported(DataFlavor flavor) {
return Arrays.asList(FLAVORS).contains(flavor);
}
@Override
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException {
if (SELECTION_FLAVOR.equals(flavor)) {
return selection;
} else if (DataFlavor.stringFlavor.equals(flavor)) {
return selection.getDisplayText();
}
throw new UnsupportedFlavorException(flavor);
}
}
public boolean canStopEditing() {
return v == null || !v.needsValidation() || v.validateAgainst(vOrig);
}
class ContextMenuListener extends MouseAdapter {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) {
doContextPopup(e.getComponent(), e.getPoint());
}
}
@Override
public void mouseClicked(MouseEvent e) {
if (e.isPopupTrigger()) {
doContextPopup(e.getComponent(), e.getPoint());
}
}
void doContextPopup(Component c, Point p) {
JPopupMenu menu = makeContextPopup(viewToModel(p));
menu.show(c, p.x, p.y);
}
}
JPopupMenu makeContextPopup(int insertionPoint) {
final List<CodeAtom> missing = v.getMissingTags(vOrig);
final int correctedPoint = v.findSelectionEnd(insertionPoint);
JPopupMenu menu = new JPopupMenu();
for (final CodeAtom atom : missing) {
JMenuItem restoreOneItem = menu.add("Restore Missing Tag: " + atom.getData());
restoreOneItem.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
v.replaceSelection(correctedPoint, correctedPoint, Arrays.asList(atom));
syncModelToView();
}
});
}
if (missing.size() != 1) {
// Only offer Restore All if there are zero tags (to make the
// feature more visible) or if there are multiple tags. The
// single-tag case is handled above.
JMenuItem restoreAllItem = menu.add("Restore All Missing Tags");
restoreAllItem.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
v.replaceSelection(correctedPoint, correctedPoint, missing);
syncModelToView();
}
});
restoreAllItem.setEnabled(!missing.isEmpty());
}
return menu;
}
void prepareEditingUI() {
menuFrame = new JFrame();
menuFrame.setUndecorated(true);
menuFrame.setAlwaysOnTop(true);
menuFrame.setAutoRequestFocus(false);
menuFrame.setFocusable(false);
Container c = menuFrame.getContentPane();
c.setLayout(new BorderLayout());
final JButton button = new JButton();
button.setFocusable(false);
button.setBorder(new EmptyBorder(4, 4, 4, 4));
Toolkit kit = Toolkit.getDefaultToolkit();
ImageIcon icon = new ImageIcon(kit.getImage(Ocelot.class.getResource("ic_settings_black_14px.png")));
button.setIcon(icon);
ImageIcon pressedIcon = new ImageIcon(kit.getImage(Ocelot.class.getResource("ic_settings_white_14px.png")));
button.setPressedIcon(pressedIcon);
c.add(button, BorderLayout.CENTER);
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
Component c = ((Component) e.getSource());
Point p = c.getLocation();
p.translate(0, c.getHeight());
makeContextPopup(getCaretPosition()).show(c, p.x, p.y);
}
});
menuFrame.pack();
addFocusListener(new FocusListener() {
@Override
public void focusLost(FocusEvent e) {
if (e.isTemporary()) {
// Loss is temporary when e.g. the program is no longer the
// active one.
menuFrame.setVisible(false);
}
}
@Override
public void focusGained(FocusEvent e) {
setFrameLocation();
}
});
addHierarchyBoundsListener(new HierarchyBoundsListener() {
@Override
public void ancestorMoved(HierarchyEvent e) {
setFrameLocation();
}
@Override
public void ancestorResized(HierarchyEvent e) {
setFrameLocation();
}
});
}
void setFrameLocation() {
Rectangle visible = getVisibleRect();
boolean showFrame = isShowing() && !visible.isEmpty() && visible.y == 0
&& visible.height >= menuFrame.getHeight();
menuFrame.setVisible(showFrame);
if (showFrame) {
Container parent = getParent();
Point p = parent.getLocationOnScreen();
p.translate(parent.getWidth() + 4, 0);
menuFrame.setLocation(p);
}
}
void closeEditingUI() {
if (menuFrame != null) {
menuFrame.dispose();
}
}
}