blob: 68135259e93c17e2e67bc54dd73eb7f71890b3ec [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.uibuilder.surface;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.tools.idea.uibuilder.api.DragType;
import com.android.tools.idea.uibuilder.api.InsertType;
import com.android.tools.idea.uibuilder.model.*;
import com.google.common.collect.Lists;
import com.intellij.ide.IdeTooltipManager;
import com.intellij.openapi.application.Result;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlFile;
import com.intellij.psi.xml.XmlTag;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.util.PsiNavigateUtil;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.Transferable;
import java.awt.dnd.*;
import java.awt.event.*;
import java.util.Collections;
import java.util.List;
import static com.android.tools.idea.uibuilder.model.SelectionHandle.PIXEL_MARGIN;
import static com.android.tools.idea.uibuilder.model.SelectionHandle.PIXEL_RADIUS;
/**
* The {@linkplain InteractionManager} is is the central manager of interactions; it is responsible
* for recognizing when particular interactions should begin and terminate. It
* listens to the drag, mouse and keyboard systems to find out when to start
* interactions and in order to update the interactions along the way.
*/
public class InteractionManager {
private static final int HOVER_DELAY_MS = Registry.intValue("ide.tooltip.initialDelay");
/** The canvas which owns this {@linkplain InteractionManager}. */
@NonNull
private final DesignSurface mySurface;
/** The currently executing {@link Interaction}, or null. */
@Nullable
private Interaction myCurrentInteraction;
/**
* The list of overlays associated with {@link #myCurrentInteraction}. Will be
* null before it has been initialized lazily by the paint routine (the
* initialized value can never be null, but it can be an empty collection).
*/
@Nullable
private List<Layer> myLayers;
/**
* Most recently seen mouse position (x coordinate). We keep a copy of this
* value since we sometimes need to know it when we aren't told about the
* mouse position (such as when a keystroke is received, such as an arrow
* key in order to tweak the current drop position)
*/
@SwingCoordinate
protected int myLastMouseX;
/**
* Most recently seen mouse position (y coordinate). We keep a copy of this
* value since we sometimes need to know it when we aren't told about the
* mouse position (such as when a keystroke is received, such as an arrow
* key in order to tweak the current drop position)
*/
@SwingCoordinate
protected int myLastMouseY;
/**
* Most recently seen mouse mask. We keep a copy of this since in some
* scenarios (such as on a drag interaction) we don't get access to it.
*/
protected int myLastStateMask;
/**
* A timer used to control when to initiate a mouse hover action. It is active only when
* the mouse is within the design surface. It gets reset every time the mouse is moved, and
* fires after a certain delay once the mouse comes to rest.
*/
private final Timer myHoverTimer;
/**
* Listener for mouse motion, click and keyboard events.
*/
private Listener myListener;
/** Drop target installed by this manager */
private DropTarget myDropTarget;
/**
* Constructs a new {@link InteractionManager} for the given
* {@link DesignSurface}.
*
* @param surface The surface which controls this {@link InteractionManager}
*/
public InteractionManager(@NonNull DesignSurface surface) {
mySurface = surface;
myHoverTimer = new Timer(HOVER_DELAY_MS, null);
myHoverTimer.setRepeats(false);
}
/**
* Returns the canvas associated with this {@linkplain InteractionManager}.
*
* @return The {@link DesignSurface} associated with this {@linkplain InteractionManager}.
* Never null.
*/
@NonNull
public DesignSurface getSurface() {
return mySurface;
}
/**
* Returns the current {@link Interaction}, if one is in progress, and otherwise returns
* null.
*
* @return The current interaction or null.
*/
@Nullable
public Interaction getCurrentInteraction() {
return myCurrentInteraction;
}
/**
* Registers all the listeners needed by the {@link InteractionManager}.
*/
public void registerListeners() {
assert myListener == null;
myListener = new Listener();
JComponent layeredPane = mySurface.getLayeredPane();
layeredPane.addMouseMotionListener(myListener);
layeredPane.addMouseListener(myListener);
layeredPane.addKeyListener(myListener);
if (!ApplicationManager.getApplication().isHeadlessEnvironment()) {
myDropTarget = new DropTarget(mySurface.getLayeredPane(), DnDConstants.ACTION_COPY_OR_MOVE, myListener, true, null);
}
myHoverTimer.addActionListener(myListener);
}
/**
* Unregisters all the listeners previously registered by
* {@link #registerListeners}.
*/
public void unregisterListeners() {
myDropTarget.removeDropTargetListener(myListener);
myHoverTimer.removeActionListener(myListener);
}
/**
* Starts the given interaction.
*/
private void startInteraction(@SwingCoordinate int x, @SwingCoordinate int y, @Nullable Interaction interaction,
int modifiers) {
if (myCurrentInteraction != null) {
finishInteraction(x, y, modifiers, true);
assert myCurrentInteraction == null;
}
if (interaction != null) {
myCurrentInteraction = interaction;
myCurrentInteraction.begin(x, y, modifiers);
myLayers = interaction.createOverlays();
}
}
/** Returns the currently active overlays, if any */
@Nullable
public List<Layer> getLayers() {
return myLayers;
}
/**
* Updates the current interaction, if any, for the given event.
*/
private void updateMouse(@SwingCoordinate int x, @SwingCoordinate int y) {
if (myCurrentInteraction != null) {
myCurrentInteraction.update(x, y, myLastStateMask);
}
}
/**
* Finish the given interaction, either from successful completion or from
* cancellation.
*
* @param x The most recent mouse x coordinate applicable to the new
* interaction, in Swing coordinates.
* @param y The most recent mouse y coordinate applicable to the new
* interaction, in Swing coordinates.
* @param modifiers The most recent modifier key state
* @param canceled True if and only if the interaction was canceled.
*/
private void finishInteraction(@SwingCoordinate int x, @SwingCoordinate int y, int modifiers, boolean canceled) {
if (myCurrentInteraction != null) {
myCurrentInteraction.end(x, y, modifiers, canceled);
if (myLayers != null) {
for (Layer layer : myLayers) {
//noinspection SSBasedInspection
layer.dispose();
}
myLayers = null;
}
myCurrentInteraction = null;
myLastStateMask = 0;
updateCursor(x, y);
mySurface.repaint();
}
}
/**
* Update the cursor to show the type of operation we expect on a mouse press:
* <ul>
* <li>Over a selection handle, show a directional cursor depending on the position of
* the selection handle
* <li>Over a widget, show a move (hand) cursor
* <li>Otherwise, show the default arrow cursor
* </ul>
*/
void updateCursor(@SwingCoordinate int x, @SwingCoordinate int y) {
// We don't hover on the root since it's not a widget per see and it is always there.
ScreenView screenView = mySurface.getScreenView(x, y);
if (screenView == null) {
mySurface.setCursor(null);
return;
}
SelectionModel selectionModel = screenView.getSelectionModel();
if (!selectionModel.isEmpty()) {
int mx = Coordinates.getAndroidX(screenView, x);
int my = Coordinates.getAndroidY(screenView, y);
int max = Coordinates.getAndroidDimension(screenView, PIXEL_RADIUS + PIXEL_MARGIN);
SelectionHandle handle = selectionModel.findHandle(mx, my, max);
if (handle != null) {
Cursor cursor = handle.getCursor();
if (cursor != mySurface.getCursor()) {
mySurface.setCursor(cursor);
}
return;
}
// See if it's over a selected view
NlComponent component = selectionModel.findComponent(mx, my);
if (component == null || component.isRoot()) {
// Finally pick any unselected component in the model under the cursor
component = screenView.getModel().findLeafAt(mx, my, false);
}
if (component != null && !component.isRoot()) {
Cursor cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
if (cursor != mySurface.getCursor()) {
mySurface.setCursor(cursor);
}
return;
}
}
mySurface.setCursor(null);
}
/**
* Helper class which implements the {@link MouseMotionListener},
* {@link MouseListener} and {@link KeyListener} interfaces.
*/
private class Listener implements MouseMotionListener, MouseListener, KeyListener, DropTargetListener, ActionListener {
// --- Implements MouseListener ----
@Override
public void mouseClicked(@NonNull MouseEvent event) {
if (event.getClickCount() == 2 && event.getButton() == MouseEvent.BUTTON1) {
// Warp to the text editor and show the corresponding XML for the
// double-clicked widget
int x = event.getX();
int y = event.getY();
ScreenView screenView = mySurface.getScreenView(x, y);
if (screenView != null) {
NlComponent component = Coordinates.findComponent(screenView, x, y);
if (component != null) {
PsiNavigateUtil.navigate(component.getTag());
}
}
}
}
@Override
public void mousePressed(@NonNull MouseEvent event) {
if(event.getID() == MouseEvent.MOUSE_PRESSED){
mySurface.getLayeredPane().requestFocusInWindow();
}
myLastMouseX = event.getX();
myLastMouseY = event.getY();
myLastStateMask = event.getModifiersEx();
// Not yet used. Should be, for Mac and Linux.
}
@Override
public void mouseReleased(@NonNull MouseEvent event) {
//ControlPoint mousePos = ControlPoint.create(mySurface, e);
int x = event.getX();
int y = event.getY();
int modifiers = event.getModifiersEx();
if (myCurrentInteraction == null) {
// Just a click, select
ScreenView screenView = mySurface.getScreenView(x, y);
if (screenView == null) {
return;
}
SelectionModel selectionModel = screenView.getSelectionModel();
NlComponent component = Coordinates.findComponent(screenView, x, y);
if (component == null) {
// Clicked component resize handle?
int mx = Coordinates.getAndroidX(screenView, x);
int my = Coordinates.getAndroidY(screenView, y);
int max = Coordinates.getAndroidDimension(screenView, PIXEL_RADIUS + PIXEL_MARGIN);
SelectionHandle handle = selectionModel.findHandle(mx, my, max);
if (handle != null) {
component = handle.component;
}
}
List<NlComponent> components = component != null ? Collections.singletonList(component) : Collections.<NlComponent>emptyList();
screenView.getSelectionModel().setSelection(components);
mySurface.repaint();
}
if (myCurrentInteraction == null) {
updateCursor(x, y);
} else {
finishInteraction(x, y, modifiers, false);
}
mySurface.repaint();
}
@Override
public void mouseEntered(@NonNull MouseEvent event) {
myHoverTimer.restart();
mySurface.resetHover();
}
@Override
public void mouseExited(@NonNull MouseEvent event) {
myHoverTimer.stop();
mySurface.resetHover();
}
// --- Implements MouseMotionListener ----
@Override
public void mouseDragged(MouseEvent event) {
int x = event.getX();
int y = event.getY();
if (myCurrentInteraction != null) {
myLastMouseX = x;
myLastMouseY = y;
myLastStateMask = event.getModifiersEx();
myCurrentInteraction.update(myLastMouseX, myLastMouseY, myLastStateMask);
} else {
x = myLastMouseX; // initiate the drag from the mousePress location, not the point we've dragged to
y = myLastMouseY;
int modifiers = event.getModifiersEx();
boolean toggle = (modifiers & (InputEvent.SHIFT_MASK | InputEvent.CTRL_MASK)) != 0;
ScreenView screenView = mySurface.getScreenView(x, y);
if (screenView == null) {
return;
}
Interaction interaction;
SelectionModel selectionModel = screenView.getSelectionModel();
// Dragging on top of a selection handle: start a resize operation
int ax = Coordinates.getAndroidX(screenView, x);
int ay = Coordinates.getAndroidY(screenView, y);
int max = Coordinates.getAndroidDimension(screenView, PIXEL_RADIUS + PIXEL_MARGIN);
SelectionHandle handle = selectionModel.findHandle(ax, ay, max);
if (handle != null) {
interaction = new ResizeInteraction(screenView, handle.component, handle);
} else {
NlModel model = screenView.getModel();
NlComponent component = model.findLeafAt(ax, ay, false);
if (component == null || component.isRoot()) {
// Dragging on the background/root view: start a marquee selection
interaction = new MarqueeInteraction(screenView, toggle);
}
else {
List<NlComponent> dragged;
// Dragging over a non-root component: move the set of components (if the component dragged over is
// part of the selection, drag them all, otherwise drag just this component)
if (selectionModel.isSelected(component)) {
dragged = Lists.newArrayList();
// Make sure the primary is the first element
NlComponent primary = selectionModel.getPrimary();
if (primary != null) {
if (primary.isRoot()) {
primary = null;
} else {
dragged.add(primary);
}
}
for (NlComponent selected : selectionModel.getSelection()) {
if (!selected.isRoot() && selected != primary) {
dragged.add(selected);
}
}
}
else {
dragged = Collections.singletonList(component);
}
interaction = new DragDropInteraction(mySurface, dragged);
}
}
startInteraction(x, y, interaction, modifiers);
}
myHoverTimer.restart();
mySurface.resetHover();
}
@Override
public void mouseMoved(MouseEvent event) {
int x = event.getX();
int y = event.getY();
myLastMouseX = x;
myLastMouseY = y;
myLastStateMask = event.getModifiersEx();
if ((myLastStateMask & InputEvent.BUTTON1_DOWN_MASK) != 0) {
if (myCurrentInteraction != null) {
updateMouse(x, y);
mySurface.repaint();
}
} else {
updateCursor(x, y);
}
myHoverTimer.restart();
mySurface.resetHover();
}
// --- Implements KeyListener ----
@Override
public void keyTyped(KeyEvent event) {
}
@Override
public void keyPressed(KeyEvent event) {
myLastStateMask = event.getModifiersEx();
// Workaround for the fact that in keyPressed the current state
// mask is not yet updated
int keyCode = event.getKeyCode();
char keyChar = event.getKeyChar();
if (keyCode == KeyEvent.VK_SHIFT) {
myLastStateMask |= InputEvent.SHIFT_MASK;
}
if (keyCode == KeyEvent.VK_META) {
myLastStateMask |= InputEvent.META_MASK;
}
if (keyCode == KeyEvent.VK_CONTROL) {
myLastStateMask |= InputEvent.CTRL_MASK;
}
// Give interactions a first chance to see and consume the key press
if (myCurrentInteraction != null) {
// unless it's "Escape", which cancels the interaction
if (keyCode == KeyEvent.VK_ESCAPE) {
finishInteraction(myLastMouseX, myLastMouseY, myLastStateMask, true);
return;
}
if (myCurrentInteraction.keyPressed(event)) {
return;
}
}
// Fall back to canvas actions for the key press
//mySurface.handleKeyPressed(e);
if (keyChar == '1') {
mySurface.zoomActual();
} else if (keyChar == 'r') {
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView != null) {
screenView.getModel().requestRender();
}
mySurface.zoomIn();
} else if (keyChar == '+') {
mySurface.zoomIn();
} else if (keyChar == 'b') {
DesignSurface.ScreenMode nextMode = mySurface.getScreenMode().next();
mySurface.setScreenMode(nextMode);
} else if (keyChar == '-') {
mySurface.zoomOut();
} else if (keyChar == '0') {
mySurface.zoomToFit();
} else if (keyChar == 'd') {
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView != null) {
screenView.switchDevice();
}
} else if (keyChar == 'o') {
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView != null) {
screenView.toggleOrientation();
}
} else if (keyChar == 'f') {
mySurface.toggleDeviceFrames();
} else if (keyCode == KeyEvent.VK_DELETE || keyCode == KeyEvent.VK_BACK_SPACE) {
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView != null) {
SelectionModel model = screenView.getSelectionModel();
if (!model.isEmpty()) {
List<NlComponent> selection = model.getSelection();
screenView.getModel().delete(selection);
}
}
}
}
@Override
public void keyReleased(KeyEvent event) {
myLastStateMask = event.getModifiersEx();
// Workaround for the fact that in keyPressed the current state
// mask is not yet updated
if (event.getKeyCode() == KeyEvent.VK_SHIFT) {
myLastStateMask |= InputEvent.SHIFT_MASK;
}
if (event.getKeyCode() == KeyEvent.VK_META) {
myLastStateMask |= InputEvent.META_MASK;
}
if (event.getKeyCode() == KeyEvent.VK_CONTROL) {
myLastStateMask |= InputEvent.CTRL_MASK;
}
if (myCurrentInteraction != null) {
myCurrentInteraction.keyReleased(event);
}
}
// ---- Implements DropTargetListener ----
@Override
public void dragEnter(DropTargetDragEvent dragEvent) {
if (myCurrentInteraction == null) {
NlDropEvent event = new NlDropEvent(dragEvent);
Point location = event.getLocation();
myLastMouseX = location.x;
myLastMouseY = location.y;
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView == null) {
event.reject();
return;
}
NlModel model = screenView.getModel();
DnDTransferItem item = NlModel.getTransferItem(event.getTransferable(), true /* allow placeholders */);
if (item == null) {
event.reject();
return;
}
DragType dragType = event.getDropAction() == DnDConstants.ACTION_COPY ? DragType.COPY : DragType.MOVE;
InsertType insertType = model.determineInsertType(dragType, item, true /* preview */);
List<NlComponent> dragged = model.createComponents(screenView, item, insertType);
if (dragged == null) {
event.reject();
return;
}
int yOffset = 0;
for (NlComponent component : dragged) {
// todo: keep original relative position?
component.x = Coordinates.getAndroidX(screenView, myLastMouseX) - component.w / 2;
component.y = Coordinates.getAndroidY(screenView, myLastMouseY) - component.h / 2 + yOffset;
yOffset += component.h;
}
DragDropInteraction interaction = new DragDropInteraction(mySurface, dragged);
interaction.setType(dragType);
interaction.setTransferItem(item);
startInteraction(myLastMouseX, myLastMouseY, interaction, 0);
// This determines the icon presented to the user while dragging.
// If we are dragging a component from the palette then use the icon for a copy, otherwise show the icon
// that reflects the users choice i.e. controlled by the modifier key.
event.accept(insertType.isCreate() ? DnDConstants.ACTION_COPY : event.getDropAction());
}
}
@Override
public void dragOver(DropTargetDragEvent dragEvent) {
NlDropEvent event = new NlDropEvent(dragEvent);
Point location = event.getLocation();
myLastMouseX = location.x;
myLastMouseY = location.y;
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView != null && myCurrentInteraction instanceof DragDropInteraction) {
DragDropInteraction interaction = (DragDropInteraction)myCurrentInteraction;
interaction.update(myLastMouseX, myLastMouseY, myLastStateMask);
DragType dragType = event.getDropAction() == DnDConstants.ACTION_COPY ? DragType.COPY : DragType.MOVE;
interaction.setType(dragType);
NlModel model = screenView.getModel();
InsertType insertType = model.determineInsertType(dragType, interaction.getTransferItem(), true /* preview */);
// This determines the icon presented to the user while dragging.
// If we are dragging a component from the palette then use the icon for a copy, otherwise show the icon
// that reflects the users choice i.e. controlled by the modifier key.
event.accept(insertType.isCreate() ? DnDConstants.ACTION_COPY : event.getDropAction());
} else {
event.reject();
}
}
@Override
public void dropActionChanged(DropTargetDragEvent event) {
}
@Override
public void dragExit(DropTargetEvent event) {
if (myCurrentInteraction instanceof DragDropInteraction) {
finishInteraction(myLastMouseX, myLastMouseY, myLastStateMask, true /* cancel interaction */);
}
}
@Override
public void drop(final DropTargetDropEvent dropEvent) {
NlDropEvent event = new NlDropEvent(dropEvent);
Point location = event.getLocation();
myLastMouseX = location.x;
myLastMouseY = location.y;
InsertType insertType = performDrop(event.getDropAction(), event.getTransferable());
if (insertType != null) {
// This determines how the DnD source acts to a completed drop.
event.accept(insertType == InsertType.COPY ? event.getDropAction() : DnDConstants.ACTION_COPY);
event.complete();
} else {
event.reject();
}
}
@Nullable
private InsertType performDrop(int dropAction, @Nullable Transferable transferable) {
if (!(myCurrentInteraction instanceof DragDropInteraction)) {
return null;
}
InsertType insertType = updateDropInteraction(dropAction, transferable);
finishInteraction(myLastMouseX, myLastMouseY, myLastStateMask, (insertType == null));
return insertType;
}
@Nullable
private InsertType updateDropInteraction(int dropAction, @Nullable Transferable transferable) {
if (transferable == null) {
return null;
}
DnDTransferItem item = NlModel.getTransferItem(transferable, false /* no placeholders */);
if (item == null) {
return null;
}
ScreenView screenView = mySurface.getScreenView(myLastMouseX, myLastMouseY);
if (screenView == null) {
return null;
}
NlModel model = screenView.getModel();
DragType dragType = dropAction == DnDConstants.ACTION_COPY ? DragType.COPY : DragType.MOVE;
InsertType insertType = model.determineInsertType(dragType, item, false /* not for preview */);
DragDropInteraction interaction = (DragDropInteraction)myCurrentInteraction;
assert interaction != null;
interaction.setType(dragType);
interaction.setTransferItem(item);
List<NlComponent> dragged = interaction.getDraggedComponents();
List<NlComponent> components;
if (insertType.isMove()) {
components = model.getSelectionModel().getSelection();
}
else {
components = model.createComponents(screenView, item, insertType);
if (components == null) {
return null; // User cancelled
}
}
if (dragged.size() != components.size()) {
throw new AssertionError(
String.format("Problem with drop: dragged.size(%1$d) != components.size(%1$d)", dragged.size(), components.size()));
}
for (int index = 0; index < dragged.size(); index++) {
components.get(index).x = dragged.get(index).x;
components.get(index).y = dragged.get(index).y;
}
dragged.clear();
dragged.addAll(components);
return insertType;
}
// --- Implements ActionListener ----
@Override
public void actionPerformed(ActionEvent e) {
if (e.getSource() != myHoverTimer) {
return;
}
int x = myLastMouseX; // initiate the drag from the mousePress location, not the point we've dragged to
int y = myLastMouseY;
// TODO: find the correct tooltip? to show
mySurface.hover(x, y);
}
}
@VisibleForTesting
public Object getListener() {
return myListener;
}
}