package abbot.editor;
import java.awt.*;
import java.awt.datatransfer.Transferable;
import java.awt.dnd.*;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.net.URL;
import java.util.*;
import java.util.List;
import javax.swing.*;
import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.table.*;
import abbot.Log;
import abbot.script.*;
/** Provides a component to edit a test script. A cursor indicates where
insertions will be positioned. Supports drag & drop within the component
itself.<p>
Actions supported:<br>
move-rows-up<br>
move-rows-down<br>
toggle<br>
*/
public class ScriptTable extends JTable implements Autoscroll {
private int cursorRow = 0;
private Sequence cursorParent = null;
private int cursorParentIndex = 0;
private int cursorDepth = 0;
private boolean isDragging = false;
private DragSource dragSource;
private DragSourceListener dragSourceListener;
private static Icon openIcon;
private static Icon closedIcon;
private static int baseIndent;
private static final int MARGIN = 4;
static {
URL url1 = ScriptTable.class.getResource("icons/triangle-dn.gif");
URL url2 = ScriptTable.class.getResource("icons/triangle-rt.gif");
if (url1 != null && url2 != null) {
openIcon = new ImageIcon(url1);
closedIcon = new ImageIcon(url2);
}
else {
BasicTreeUI ui = (BasicTreeUI)(new JTree().getUI());
openIcon = ui.getExpandedIcon();
closedIcon = ui.getCollapsedIcon();
}
baseIndent = openIcon.getIconWidth();
}
private ScriptModel model;
public ScriptTable() {
this(new ScriptModel());
}
public ScriptTable(ScriptModel scriptModel) {
super(scriptModel);
setSelectionModel(new SelectionModel());
model = scriptModel;
TableCellRenderer cr = new ScriptTableCellRenderer();
setDefaultRenderer(Object.class, cr);
Dimension spacing = getIntercellSpacing();
spacing.height = 2;
setIntercellSpacing(spacing);
initDragDrop();
// Detect clicks on the table in order to position the cursor
// and expand entries.
MouseListener ml = new MouseAdapter() {
public void mouseClicked(MouseEvent me) {
if (me.getModifiers() != InputEvent.BUTTON1_MASK)
return;
if (me.getClickCount() == 2) {
int row = rowAtPoint(me.getPoint());
Log.debug("Toggling row at " + row);
toggle(row);
}
else {
click(me.getPoint());
}
}
};
addMouseListener(ml);
// Set up our custom actions; note that there are no default input
// bindings
ActionMap map = getActionMap();
map.put("move-rows-up", new AbstractAction() {
public void actionPerformed(ActionEvent ev) {
moveUp();
}
});
map.put("move-rows-down", new AbstractAction() {
public void actionPerformed(ActionEvent ev) {
moveDown();
}
});
map.put("toggle", new AbstractAction() {
public void actionPerformed(ActionEvent ev) {
int selRow = getSelectedRow();
if (selRow != -1) {
toggle(selRow);
}
}
});
}
/** Toggle the open/closed state of a sequence. */
public void toggle(int row) {
int[] rows = getSelectedRows();
if (rows.length > 0) {
int anchor = getSelectionModel().getAnchorSelectionIndex();
Step first = model.getStepAt(rows[0]);
int lastRow = rows[rows.length-1];
Step last = model.getStepAt(lastRow);
Step afterLast = lastRow < getRowCount() - 1
? model.getStepAt(lastRow + 1) : null;
clearSelection();
model.toggle(row);
int index0 = model.getRowOf(first);
int index1 = model.getRowOf(last);
// If the last step in the selection becomes hidden, change the
// selection and the cursor position.
if (index1 == -1) {
index1 = afterLast == null
? row : model.getRowOf(afterLast) - 1;
}
if (anchor == index0) {
Log.debug("Updating selection to " + index0 + " to " + index1);
setRowSelectionInterval(index0, index1);
}
else {
Log.debug("Updating selection to " + index1 + " to " + index0);
setRowSelectionInterval(index1, index0);
}
}
else {
model.toggle(row);
}
}
/** Return the bounding for the given cell. If the step at the given row
* is contained within a sequence, the rect will be offset to the right.
*/
public Rectangle getCellRect(int row, int col, boolean includeBorder) {
Rectangle rect = super.getCellRect(row, col, includeBorder);
int indent = getIndentation(row);
rect.x += indent;
rect.width -= indent;
return rect;
}
/** Return the number of pixels offset from the left edge of the table for
the given row.
*/
public int getIndentation(int row) {
return getDepthIndentation(model.getNestingDepthAt(row));
}
/** Return the number of pixels offset from the left edge of the table for
the given level of indentation.
*/
public int getDepthIndentation(int depth) {
return baseIndent * depth;
}
private void click(Point pt) {
int row = rowAtPoint(pt);
if (row == -1) {
clearSelection();
setCursorLocation(getRowCount());
}
else {
Rectangle rect = getCellRect(row, 0, true);
// If the click is on the open/close icon, then toggle
if ((model.getStepAt(row) instanceof Sequence)
&& pt.x >= rect.x
&& pt.x < rect.x + baseIndent
&& pt.y > rect.y + rect.height / MARGIN
&& pt.y < rect.y + rect.height * (MARGIN-1)/MARGIN) {
toggle(row);
}
else {
setCursorLocation(pt);
}
}
}
private void initDragDrop() {
int action = DnDConstants.ACTION_MOVE;
dragSource = DragSource.getDefaultDragSource();
DragGestureListener dgl = new DGListener();
dragSource.createDefaultDragGestureRecognizer(this, action, dgl);
dragSourceListener = new DSListener();
DropTarget dt = new DropTarget(this, new DTListener());
dt.setDefaultActions(DnDConstants.ACTION_MOVE);
}
/** Determine what the background color for the given step should be. */
protected Color getStepColor(Step step, boolean selected) {
return selected ? getSelectionBackground() : getBackground();
}
/** Returns the script context of the currently selected row. */
public Script getScriptContext() {
int row = getSelectedRow();
if (row == -1)
return model.getScript();
return model.getScriptOf(row);
}
/** Returns the row number of the cursor. The number of cursor locations
* is one greater than the number of table
* entries.
*/
public int getCursorRow() {
return cursorRow;
}
/** Returns the target parent of the current cursor location. */
public Sequence getCursorParent() {
return cursorParent;
}
/** Returns the target index within the parent of the current cursor
location. */
public int getCursorParentIndex() {
return cursorParentIndex;
}
protected Rectangle getCursorBounds() {
Insets insets = getInsets();
Dimension d = getSize();
Dimension m = getIntercellSpacing();
if (m.height == 0)
m.height = 1;
int width = d.width - insets.left - insets.right;
int row = Math.min(cursorRow, getRowCount()-1);
Rectangle cellRect = super.getCellRect(row, 0, false);
int y = cellRect.y;
if (cursorRow == getRowCount())
y += cellRect.height + m.height;
int indent = getDepthIndentation(cursorDepth);
return new Rectangle(indent, y - m.height, width - indent, m.height);
}
/** Given an arbitrary point within the table, return the nearest valid
row for the cursor to be placed.
*/
private int getCursorRowAtPoint(Point where) {
int row = rowAtPoint(where);
if (row == -1) {
row = where.y < 0 ? 0 : getRowCount();
}
else {
Rectangle rect = super.getCellRect(row, 0, true);
if (where.getY() > rect.y + rect.height / 2)
++row;
}
// When dragging, don't put the cursor somewhere which would produce
// no effect, or which would be illegal.
if (isDragging) {
int selStart = getSelectedRow();
int count = getSelectedRowCount();
if (row > selStart && row <= selStart + count) {
if (row > count / 2
&& selStart + count + 1 < getRowCount()) {
row = selStart + count + 1;
}
else {
row = selStart;
}
}
}
return row;
}
// FIXME sometimes the cursor row leads the selected row by two
public void setCursorLocation(Point where) {
int row = getCursorRowAtPoint(where);
setCursorLocation(row, where.x);
}
/** Set the cursor location, using the given indentation to determine the
* appropriate target parent sequence.
*/
private void setCursorLocation(int row, int indentation) {
Rectangle oldRect = getCursorBounds();
Script script = model.getScript();
if (script == null)
return;
// Can't position the cursor after a terminate step
if (script.hasTerminate() && row == getRowCount())
--row;
else if (script.hasLaunch() && row == 0)
++row;
cursorRow = row;
Sequence parent = script;
int index = row == getRowCount()
? parent.size() : parent.indexOf(model.getStepAt(row));
int depth = 0;
if (row > 0) {
// Place the cursor based on the previous step
Step prev = model.getStepAt(row - 1);
if (model.isOpen(prev)) {
parent = (Sequence)prev;
index = 0;
// Depth is one greater than the depth of the parent
depth = model.getNestingDepthAt(row - 1) + 1;
}
else {
parent = model.getParent(prev);
index = parent.indexOf(prev) + 1;
depth = model.getNestingDepthAt(row - 1);
}
int indent = getDepthIndentation(depth);
// Shift up the hierarchy until we reach the appropriate
// indentation level.
while (indent > indentation && parent != script) {
Sequence nextUp = model.getParent(parent);
index = nextUp.indexOf(parent) + 1;
parent = nextUp;
indent = getDepthIndentation(--depth);
}
}
cursorParent = parent;
cursorParentIndex = index;
cursorDepth = depth;
if (oldRect != null)
repaint(oldRect);
repaint(getCursorBounds());
}
/** Set the cursor location to a reasonable target for the given row. */
public void setCursorLocation(int row) {
setCursorLocation(row, 0);
}
protected void drawCursor(Graphics g, int row) {
g.setColor(Color.green);
((Graphics2D)g).fill(getCursorBounds());
}
/** We paint a cursor where insertions will take effect. */
public void paint(Graphics g) {
super.paint(g);
drawCursor(g, cursorRow == getRowCount()
? cursorRow-1 : cursorRow);
}
public void autoscroll(Point pt) {
Rectangle bounds = getBounds();
Log.debug("autoscroll at " + pt + " bounds " + bounds);
// Figure out which row we're on.
int row = rowAtPoint(pt);
if (row < 0)
return;
if (pt.y + bounds.y <= AUTOSCROLL_MARGIN) {
if (row > 0) --row;
}
else {
if (row < getRowCount() - 1) ++row;
}
scrollRectToVisible(getCellRect(row, 0, true));
}
private static final int AUTOSCROLL_MARGIN = 12;
public Insets getAutoscrollInsets() {
// Calculate the insets for the JTree, not the viewport the tree is
// in.
Rectangle tree = getBounds();
Rectangle view = getParent().getBounds();
return new Insets(view.y - tree.y + AUTOSCROLL_MARGIN,
view.x - tree.x + AUTOSCROLL_MARGIN,
tree.height - view.height - view.y
+ tree.y + AUTOSCROLL_MARGIN,
tree.width - view.width - view.x
+ tree.x + AUTOSCROLL_MARGIN);
}
private class ScriptTableCellRenderer extends DefaultTableCellRenderer {
public Component getTableCellRendererComponent(JTable table,
Object value,
boolean sel,
boolean focus,
int row, int col) {
// We know that the default renderer for a JTable
// is a subclass of JLabel
JLabel renderer = (JLabel)
super.getTableCellRendererComponent(table,
value, sel,
focus,
row, col);
Step step = model.getStepAt(row);
Icon icon = null;
if (step instanceof Sequence) {
icon = model.isOpen(row) ? openIcon : closedIcon;
}
renderer.setIcon(icon);
super.setBackground(getStepColor(step, sel));
setOpaque(true);
return renderer;
}
}
/** Return the first selected step. */
public Step getSelectedStep() {
return getSelectedRowCount() > 0
? model.getStepAt(getSelectedRow()) : null;
}
/** Return the set of selected steps, restricted to siblings of the first
selected row.
*/
public List getSelectedSteps() {
ArrayList list = new ArrayList();
int[] rows = getSelectedRows();
if (rows.length > 0) {
Step step = model.getStepAt(rows[0]);
Sequence parent = model.getParent(step);
for (int i=0;i < rows.length;i++) {
step = model.getStepAt(rows[i]);
if (model.getParent(step) == parent) {
list.add(step);
}
}
}
return list;
}
public boolean canMoveDown() {
int[] rows = getSelectedRows();
int max = getRowCount() - (model.getScript().hasTerminate()
? 2 : 1);
return rows[rows.length-1] < max;
}
public boolean canMoveUp() {
int row = getSelectedRow();
int min = model.getScript().hasLaunch() ? 1 : 0;
return row > min && !(model.getStepAt(row) instanceof Terminate);
}
/** Move the selected step(s) up. If the previous row is part of an open
* sequence, move to the end of the sequence. Otherwise, switch places
* with the step at the previous row.
*/
public void moveUp() {
if (!canMoveUp())
return;
List list = getSelectedSteps();
int leadRow = getSelectedRow();
Step lead = (Step)list.get(0);
Sequence parent = model.getParent(lead);
Step prev = model.getStepAt(leadRow - 1);
int targetIndex = 0;
// If the previous row to the selection is its parent, move previous
// to the parent.
if (parent.indexOf(lead) == 0) {
Log.debug("Move out of sequence");
Sequence newParent = model.getParent(parent);
targetIndex = newParent.indexOf(parent);
parent = newParent;
}
// Is the previous step part of an open sequence?
else if (model.isOpen(prev)) {
Log.debug("Move to previous empty open sequence");
parent = (Sequence)prev;
targetIndex = 0;
}
else if (model.getParent(prev) != parent) {
Log.debug("Move to previous open sequence");
parent = model.getParent(prev);
targetIndex = parent.indexOf(prev) + 1;
}
else {
Log.debug("Move previous");
targetIndex = parent.indexOf(prev);
}
moveSelectedRows(parent, targetIndex);
}
/** Move the currently selected rows down one row. */
public void moveDown() {
if (!canMoveDown()) {
Log.warn("Unexpected move down state");
return;
}
List list = getSelectedSteps();
Step lead = (Step)list.get(0);
Sequence leadParent = model.getParent(lead);
int[] rows = getSelectedRows();
Step next = model.getStepAt(rows[rows.length-1] + 1);
Sequence parent = model.getParent(next);
// Default behavior moves after the next step
int targetIndex = parent.indexOf(next) + 1;
// If the next step after the selection is not a sibling,
// make the group a sibling to its old parent
if (leadParent != parent) {
Sequence nextParent = model.getParent(leadParent);
targetIndex = nextParent.indexOf(leadParent) + 1;
parent = nextParent;
}
// If the next step is an open sequence, move into it
else if (model.isOpen(next)) {
parent = (Sequence)next;
targetIndex = 0;
}
moveSelectedRows(parent, targetIndex);
}
/** Move the currently selected rows into the given parent at the given
index.
*/
public void moveSelectedRows(Sequence parent, int index) {
List steps = getSelectedSteps();
Step first = (Step)steps.get(0);
if (parent.indexOf(first) == index)
return;
ListSelectionModel lsm = getSelectionModel();
boolean firstIsAnchor =
getSelectedRow() == lsm.getAnchorSelectionIndex();
model.moveSteps(parent, steps, index);
int firstRow = model.getRowOf(first);
if (firstIsAnchor)
lsm.setSelectionInterval(firstRow, firstRow + steps.size() - 1);
else
lsm.setSelectionInterval(firstRow + steps.size() - 1, firstRow);
}
private class DGListener implements DragGestureListener {
public void dragGestureRecognized(DragGestureEvent e) {
Point where = e.getDragOrigin();
int firstRow = getSelectedRow();
if (firstRow == -1)
return;
if ((e.getDragAction() & DnDConstants.ACTION_MOVE) != 0) {
Transferable tf = getSelectedRowCount() > 1
? new StepTransferable(getSelectedSteps())
: new StepTransferable(getSelectedStep());
try {
Rectangle rect = getCellRect(firstRow, 0, true);
int count = getSelectedRowCount();
rect.height *= count;
Point offset = new Point(rect.x - where.x,
rect.y - where.y);
rect.x = rect.y = 0;
BufferedImage image =
new BufferedImage(rect.width, rect.height,
BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D graphics = image.createGraphics();
graphics.setColor(Color.gray);
--rect.width;--rect.height;
graphics.draw(rect);
graphics.dispose();
e.startDrag(DragSource.DefaultMoveDrop,
image, offset,
tf, dragSourceListener);
isDragging = true;
}
catch(InvalidDnDOperationException exc) {
Log.warn(exc);
}
}
}
}
/** Listens to events coming from the source of the drag action. */
private class DSListener implements DragSourceListener {
public void dragDropEnd(DragSourceDropEvent e) {
Log.debug("drag drop end " + e.getDropAction());
// OSX bug makes this fail, so do it in the target listener instead
if (!e.getDropSuccess()) {
Log.debug("drop failed");
}
}
public void dragEnter(DragSourceDragEvent e) {
Log.debug( "drag enter " + e.getDropAction());
DragSourceContext context = e.getDragSourceContext();
// intersection of the users selected action, and the source and
// target actions
int action = e.getDropAction();
if ((action & DnDConstants.ACTION_MOVE) != 0) {
context.setCursor(DragSource.DefaultMoveDrop);
}
else {
context.setCursor(DragSource.DefaultMoveNoDrop);
}
}
public void dragOver(DragSourceDragEvent e) { }
public void dragExit(DragSourceEvent e) { }
public void dropActionChanged(DragSourceDragEvent e) {
Log.debug("action changed " + e.getDropAction());
DragSourceContext context = e.getDragSourceContext();
context.setCursor(DragSource.DefaultMoveNoDrop);
}
}
/** Listens to events coming from the target of the drag action. */
private class DTListener implements DropTargetListener {
public void dragEnter(DropTargetDragEvent e) {
if (!isDragAcceptable(e)) {
e.rejectDrag();
}
else {
e.acceptDrag(DnDConstants.ACTION_MOVE);
}
}
public void dragExit(DropTargetEvent e) { }
public void dropActionChanged(DropTargetDragEvent e) {
if (!isDragAcceptable(e)) {
e.rejectDrag();
}
else {
e.acceptDrag(DnDConstants.ACTION_MOVE);
}
}
public void dragOver(DropTargetDragEvent e) {
Log.debug("drag over target " + e.getDropAction());
if (isDragAcceptable(e)) {
e.acceptDrag(DnDConstants.ACTION_MOVE);
Rectangle last = getCursorBounds();
setCursorLocation(e.getLocation());
Rectangle current = getCursorBounds();
paintImmediately(last);
paintImmediately(current);
}
else {
e.rejectDrag();
}
}
public void drop(DropTargetDropEvent e) {
Log.debug("drop successful " + e.getDropAction());
if (!isDropAcceptable(e)) {
e.rejectDrop();
}
else {
e.acceptDrop(DnDConstants.ACTION_MOVE);
moveSelectedRows(cursorParent, cursorParentIndex);
}
e.dropComplete(true);
}
public boolean isDragAcceptable(DropTargetDragEvent e) {
Log.debug("drag action is " + e.getDropAction());
return e.isDataFlavorSupported(StepTransferable.STEP_FLAVOR);
}
public boolean isDropAcceptable(DropTargetDropEvent e) {
Log.debug("drop action is " + e.getDropAction());
return e.isDataFlavorSupported(StepTransferable.STEP_FLAVOR);
}
}
/** If any sub-steps of a sequence are selected, they <i>all</i> must be
selected. Also select all children when selecting an open sequence.
*/
private class SelectionModel extends DefaultListSelectionModel {
public SelectionModel() {
setSelectionMode(SINGLE_INTERVAL_SELECTION);
}
private void fixSelection() {
if (getSelectedRowCount() == 0
|| (getSelectedRowCount() == 1
&& !model.isOpen(getSelectedRow()))) {
return;
}
// Ensure the first selection has at maximum the minimum depth
// Ensure the row after the last selection has at minimum the
// minimum depth.
int anchor = getAnchorSelectionIndex();
int lead = getLeadSelectionIndex();
int lo, hi;
if (anchor < lead) {
lo = anchor; hi = lead;
}
else {
lo = lead; hi = anchor;
}
int loDepth = model.getNestingDepthAt(lo);
int minDepth = loDepth;
for (int i=lo+1;i <= hi;i++) {
minDepth = Math.min(minDepth, model.getNestingDepthAt(i));
}
if (loDepth > minDepth) {
for (int i=lo-1;i >= 0;i--) {
if (model.getNestingDepthAt(i) == minDepth) {
Log.debug("Changing low end to " + i);
if (lo == anchor)
setSelectionInterval(lo = i, hi);
else
setSelectionInterval(hi, lo = i);
break;
}
}
}
int last = hi + 1;
while (last < getRowCount()
&& model.getNestingDepthAt(last) > minDepth) {
++last;
}
if (last > hi + 1) {
Log.debug("Changing hi end to " + (last - 1));
if (hi == lead)
setSelectionInterval(lo, last - 1);
else
setSelectionInterval(last - 1, lo);
}
}
public void addSelectionInterval(int index0, int index1) {
super.addSelectionInterval(index0, index1);
fixSelection();
}
public void removeSelectionInterval(int index0, int index1) {
super.removeSelectionInterval(index0, index1);
fixSelection();
}
public void setAnchorSelectionIndex(int index) {
super.setAnchorSelectionIndex(index);
fixSelection();
}
public void setLeadSelectionIndex(int index) {
super.setLeadSelectionIndex(index);
fixSelection();
}
public void setSelectionInterval(int index0, int index1) {
super.setSelectionInterval(index0, index1);
fixSelection();
}
public void insertIndexInterval(int index, int length, boolean bfore) {
super.insertIndexInterval(index, length, bfore);
fixSelection();
}
public void removeIndexInterval(int idx0, int idx1) {
super.removeIndexInterval(idx0, idx1);
fixSelection();
}
}
}