/*
* Copyright (C) 2013 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.editors.navigation;
import com.android.navigation.Dimension;
import com.android.navigation.*;
import com.android.navigation.NavigationModel.Event;
import com.android.navigation.NavigationModel.Event.Operation;
import com.android.tools.idea.actions.AndroidShowNavigationEditor;
import com.android.tools.idea.configurations.Configuration;
import com.android.tools.idea.editors.navigation.macros.Analyser;
import com.android.tools.idea.editors.navigation.macros.CodeGenerator;
import com.android.tools.idea.rendering.ModuleResourceRepository;
import com.intellij.AppTopics;
import com.intellij.codeHighlighting.BackgroundEditorHighlighter;
import com.intellij.ide.structureView.StructureViewBuilder;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.application.Application;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.fileEditor.*;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.ComboBox;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.UserDataHolderBase;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.VirtualFileAdapter;
import com.intellij.openapi.vfs.VirtualFileEvent;
import com.intellij.openapi.vfs.VirtualFileManager;
import com.intellij.ui.HyperlinkLabel;
import com.intellij.ui.IdeBorderFactory;
import com.intellij.ui.SideBorder;
import com.intellij.ui.components.JBScrollPane;
import icons.AndroidIcons;
import org.jetbrains.android.facet.AndroidFacet;
import org.jetbrains.android.facet.ResourceFolderManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.Point;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.beans.PropertyChangeListener;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.List;
import static com.android.tools.idea.editors.navigation.NavigationView.GAP;
@SuppressWarnings("UseOfSystemOutOrSystemErr")
public class NavigationEditor implements FileEditor {
private static final String TOOLBAR = "NavigationEditorToolbar";
private static final Logger LOG = Logger.getInstance("#" + NavigationEditor.class.getName());
private static final boolean DEBUG = false;
private static final String NAME = "Navigation";
private static final int INITIAL_FILE_BUFFER_SIZE = 1000;
private static final int SCROLL_UNIT_INCREMENT = 20;
private static final NavigationModel.Event PROJECT_READ = new Event(Operation.UPDATE, Object.class);
private static final com.android.navigation.Dimension UNATTACHED_STRIDE = new com.android.navigation.Dimension(50, 50);
private final UserDataHolderBase myUserDataHolder = new UserDataHolderBase();
@Nullable
private RenderingParameters myRenderingParams;
private NavigationModel myNavigationModel;
private SelectionModel mySelectionModel = new SelectionModel();
private final VirtualFile myFile;
private JComponent myComponent;
private Inspector myInspector;
private CodeGenerator myCodeGenerator;
private boolean myModified;
private boolean myPendingFileSystemChanges;
private Analyser myAnalyser;
private final Listener<NavigationModel.Event> myNavigationModelListener;
private final ResourceFolderManager.ResourceFolderListener myResourceFolderListener;
private VirtualFileAdapter myVirtualFileListener;
private final ErrorHandler myErrorHandler;
public NavigationEditor(Project project, VirtualFile file) {
// Listen for 'Save All' events
FileDocumentManagerListener saveListener = new FileDocumentManagerAdapter() {
@Override
public void beforeAllDocumentsSaving() {
try {
saveFile();
}
catch (IOException e) {
LOG.error("Unexpected exception while saving navigation file", e);
}
}
};
project.getMessageBus().connect(this).subscribe(AppTopics.FILE_DOCUMENT_SYNC, saveListener);
myFile = file;
myErrorHandler = new ErrorHandler();
myRenderingParams = NavigationView.getRenderingParams(project, file, myErrorHandler);
if (myRenderingParams != null) {
Configuration configuration = myRenderingParams.myConfiguration;
Module module = configuration.getModule();
myAnalyser = new Analyser(project, module);
try {
myNavigationModel = read(file);
myCodeGenerator = new CodeGenerator(myNavigationModel, module);
NavigationView editor = new NavigationView(myRenderingParams, myNavigationModel, mySelectionModel);
// Create UI
{
JPanel panel = new JPanel(new BorderLayout());
{
JComponent toolBar = createToolbar(editor);
panel.add(toolBar, BorderLayout.NORTH);
}
{
JSplitPane splitPane = new JSplitPane();
{
JBScrollPane scrollPane = new JBScrollPane(editor);
scrollPane.getVerticalScrollBar().setUnitIncrement(SCROLL_UNIT_INCREMENT);
splitPane.setLeftComponent(scrollPane);
}
{
myInspector = new Inspector(mySelectionModel);
splitPane.setRightComponent(new JBScrollPane(myInspector.container));
}
splitPane.setDividerLocation(.7);
panel.add(splitPane);
}
myComponent = panel;
}
}
catch (FileReadException e) {
myErrorHandler.handleError("Invalid Navigation File", e.getMessage());
if (DEBUG) {
e.printStackTrace();
}
}
}
myNavigationModelListener = new Listener<NavigationModel.Event>() {
@Override
public void notify(@NotNull NavigationModel.Event event) {
if (event.operation == Operation.INSERT && event.operandType == Transition.class) {
ArrayList<Transition> transitions = myNavigationModel.getTransitions();
Transition transition = transitions.get(transitions.size() - 1); // todo don't rely on this being the last
myCodeGenerator.implementTransition(transition);
}
if (event != PROJECT_READ) { // exempt the case when we are updating the model ourselves (because of a file read)
myModified = true;
}
}
};
myNavigationModel.getListeners().add(myNavigationModelListener);
myVirtualFileListener = new VirtualFileAdapter() {
private void somethingChanged(String changeType, @NotNull VirtualFileEvent event) {
if (DEBUG) System.out.println("NavigationEditor: fileListener:: " + changeType + ": " + event);
postDelayedRefresh();
}
@Override
public void contentsChanged(@NotNull VirtualFileEvent event) {
somethingChanged("contentsChanged", event);
}
@Override
public void fileCreated(@NotNull VirtualFileEvent event) {
somethingChanged("fileCreated", event);
}
@Override
public void fileDeleted(@NotNull VirtualFileEvent event) {
somethingChanged("fileDeleted", event);
}
};
myResourceFolderListener = new ResourceFolderManager.ResourceFolderListener() {
@Override
public void resourceFoldersChanged(@NotNull AndroidFacet facet,
@NotNull List<VirtualFile> folders,
@NotNull Collection<VirtualFile> added,
@NotNull Collection<VirtualFile> removed) {
if (DEBUG) System.out.println("NavigationEditor: resourceFoldersChanged" + folders);
postDelayedRefresh();
}
};
}
public class ErrorHandler {
public void handleError(String title, String errorMessage) {
myNavigationModel = new NavigationModel();
{
JPanel panel = new JPanel(new BorderLayout());
{
JLabel label = new JLabel(title);
label.setFont(label.getFont().deriveFont(30f));
label.setHorizontalAlignment(SwingConstants.CENTER);
panel.add(label, BorderLayout.NORTH);
}
{
JLabel label = new JLabel(errorMessage);
label.setFont(label.getFont().deriveFont(20f));
label.setHorizontalAlignment(SwingConstants.CENTER);
panel.add(label, BorderLayout.CENTER);
}
myComponent = new JBScrollPane(panel);
}
}
}
private static ResourceFolderManager getResourceFolderManager(AndroidFacet facet) {
//if (facet.isGradleProject()) {
// Ensure that the app resources have been initialized first, since
// we want it to add its own variant listeners before ours (such that
// when the variant changes, the project resources get notified and updated
// before our own update listener attempts a re-render)
ModuleResourceRepository.getModuleResources(facet, true /*createIfNecessary*/);
return facet.getResourceFolderManager();
//}
//return null;
}
private void postDelayedRefresh() {
if (DEBUG) System.out.println("NavigationEditor: postDelayedRefresh");
// Post to the event queue to coalesce events and effect re-parse when they're all in
if (!myPendingFileSystemChanges) {
myPendingFileSystemChanges = true;
final Application app = ApplicationManager.getApplication();
app.invokeLater(new Runnable() {
@Override
public void run() {
app.executeOnPooledThread(new Runnable() {
@Override
public void run() {
app.runReadAction(new Runnable() {
@Override
public void run() {
myPendingFileSystemChanges = false;
long l = System.currentTimeMillis();
updateNavigationModelFromProject();
if (DEBUG) System.out.println("Navigation Editor: model read took: " + (System.currentTimeMillis() - l) / 1000.0);
}
});
}
});
}
});
}
}
// See AndroidDesignerActionPanel
protected JComponent createToolbar(NavigationView myDesigner) {
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(IdeBorderFactory.createBorder(SideBorder.BOTTOM));
// the UI below is a temporary hack to show UX / dev. rel
{
final String dirName = myFile.getParent().getName();
JPanel combos = new JPanel(new FlowLayout());
//combos.add(new JLabel(dirName));
{
final String phone = "phone";
final String tablet = "tablet";
final ComboBox deviceSelector = new ComboBox(new Object[]{phone, tablet});
final String portrait = "portrait";
final String landscape = "landscape";
final ComboBox orientationSelector = new ComboBox(new Object[]{portrait, landscape});
deviceSelector.setSelectedItem(dirName.contains("-sw600dp") ? tablet : phone);
orientationSelector.setSelectedItem(dirName.contains("-land") ? landscape : portrait);
ActionListener actionListener = new ActionListener() {
boolean disabled = false;
@Override
public void actionPerformed(ActionEvent actionEvent) {
if (disabled) {
return;
}
Object device = deviceSelector.getSelectedItem();
Object deviceQualifier = (device == tablet) ? "-sw600dp" : "";
Object orientation = orientationSelector.getSelectedItem();
Object orientationQualifier = (orientation == landscape) ? "-land" : "";
new AndroidShowNavigationEditor()
.showNavigationEditor(myRenderingParams.myProject, "raw" + deviceQualifier + orientationQualifier, "main.nvg.xml");
disabled = true;
deviceSelector.setSelectedItem(dirName.contains("-sw600dp") ? tablet : phone);
orientationSelector.setSelectedItem(dirName.contains("-land") ? landscape : portrait);
disabled = false;
}
};
{
deviceSelector.addActionListener(actionListener);
combos.add(deviceSelector);
}
{
orientationSelector.addActionListener(actionListener);
combos.add(orientationSelector);
}
}
panel.add(combos, BorderLayout.CENTER);
}
{
ActionManager actionManager = ActionManager.getInstance();
ActionToolbar zoomToolBar = actionManager.createActionToolbar(TOOLBAR, getActions(myDesigner), true);
panel.add(zoomToolBar.getComponent(), BorderLayout.EAST);
{
HyperlinkLabel label = new HyperlinkLabel();
label.setHyperlinkTarget("http://tools.android.com/navigation-editor");
label.setHyperlinkText(" ", "What's this?", "");
panel.add(label, BorderLayout.WEST);
}
}
return panel;
}
private static class FileReadException extends Exception {
private FileReadException(Throwable throwable) {
super(throwable);
}
}
// See AndroidDesignerActionPanel
private static ActionGroup getActions(final NavigationView myDesigner) {
DefaultActionGroup group = new DefaultActionGroup();
group.add(new AnAction(null, "Zoom Out (-)", AndroidIcons.ZoomOut) {
@Override
public void actionPerformed(AnActionEvent e) {
myDesigner.zoom(false);
}
});
group.add(new AnAction(null, "Reset Zoom to 100% (1)", AndroidIcons.ZoomActual) {
@Override
public void actionPerformed(AnActionEvent e) {
myDesigner.setScale(1);
}
});
group.add(new AnAction(null, "Zoom In (+)", AndroidIcons.ZoomIn) {
@Override
public void actionPerformed(AnActionEvent e) {
myDesigner.zoom(true);
}
});
return group;
}
private static NavigationModel read(VirtualFile file) throws FileReadException {
try {
InputStream inputStream = file.getInputStream();
if (inputStream.available() == 0) {
return new NavigationModel();
}
return (NavigationModel)new XMLReader(inputStream).read();
}
catch (Exception e) {
throw new FileReadException(e);
}
}
@NotNull
@Override
public JComponent getComponent() {
return myComponent;
}
@Nullable
@Override
public JComponent getPreferredFocusedComponent() {
return null;
}
@NotNull
@Override
public String getName() {
return NAME;
}
@NotNull
@Override
public FileEditorState getState(@NotNull FileEditorStateLevel level) {
return FileEditorState.INSTANCE;
}
@Override
public void setState(@NotNull FileEditorState state) {
}
@Override
public boolean isModified() {
return myModified;
}
@Override
public boolean isValid() {
return myFile.isValid();
}
private void layoutStatesWithUnsetLocations(NavigationModel navigationModel) {
Collection<State> states = navigationModel.getStates();
final Map<State, com.android.navigation.Point> stateToLocation = navigationModel.getStateToLocation();
final Set<State> visited = new HashSet<State>();
Dimension size = myRenderingParams.getDeviceScreenSize();
Dimension gridSize = new Dimension(size.width + GAP.width, size.height + GAP.height);
final Point location = new Point(GAP.width, GAP.height);
final int gridWidth = gridSize.width;
final int gridHeight = gridSize.height;
// Gather childless roots and deal with them differently, there could be many of them
Set<State> transitionStates = getTransitionStates();
Collection<State> unattached = getNonTransitionStates(states, transitionStates);
visited.addAll(unattached);
for (State state : states) {
if (visited.contains(state)) {
continue;
}
new Object() {
public void addChildrenFor(State source) {
visited.add(source);
if (!stateToLocation.containsKey(source)) {
stateToLocation.put(source, new com.android.navigation.Point(location.x, location.y));
}
List<State> children = findDestinationsFor(source, visited);
location.x += gridWidth;
if (children.isEmpty()) {
location.y += gridHeight;
}
else {
for (State child : children) {
addChildrenFor(child);
}
}
location.x -= gridWidth;
}
}.addChildrenFor(state);
}
for (State root : unattached) {
stateToLocation.put(root, new com.android.navigation.Point(location.x, location.y));
location.x += UNATTACHED_STRIDE.width;
location.y += UNATTACHED_STRIDE.height;
}
}
private Set<State> getTransitionStates() {
Set<State> result = new HashSet<State>();
for (Transition transition : myNavigationModel.getTransitions()) {
State source = transition.getSource().getState();
State destination = transition.getDestination().getState();
result.add(source);
result.add(destination);
}
return result;
}
private static Collection<State> getNonTransitionStates(Collection<State> states, Set<State> transitionStates) {
Collection<State> unattached = new ArrayList<State>(states);
unattached.removeAll(transitionStates);
return unattached;
}
private List<State> findDestinationsFor(State source, Set<State> visited) {
java.util.List<State> result = new ArrayList<State>();
for (Transition transition : myNavigationModel.getTransitions()) {
if (transition.getSource().getState() == source) {
State destination = transition.getDestination().getState();
if (!visited.contains(destination)) {
result.add(destination);
}
}
}
return result;
}
private void updateNavigationModelFromProject() {
if (DEBUG) System.out.println("NavigationEditor: updateNavigationModelFromProject...");
if (myRenderingParams == null || myRenderingParams.myProject.isDisposed()) {
return;
}
EventDispatcher<NavigationModel.Event> listeners = myNavigationModel.getListeners();
boolean notificationWasEnabled = listeners.isNotificationEnabled();
listeners.setNotificationEnabled(false);
myNavigationModel.clear();
myNavigationModel.getTransitions().clear();
myAnalyser.deriveAllStatesAndTransitions(myNavigationModel, myRenderingParams.myConfiguration);
layoutStatesWithUnsetLocations(myNavigationModel);
listeners.setNotificationEnabled(notificationWasEnabled);
myModified = false;
listeners.notify(PROJECT_READ);
}
@Override
public void selectNotify() {
if (myRenderingParams != null) {
AndroidFacet facet = myRenderingParams.myFacet;
updateNavigationModelFromProject();
VirtualFileManager.getInstance().addVirtualFileListener(myVirtualFileListener);
getResourceFolderManager(facet).addListener(myResourceFolderListener);
}
}
@Override
public void deselectNotify() {
if (myRenderingParams != null) {
AndroidFacet facet = myRenderingParams.myFacet;
VirtualFileManager.getInstance().removeVirtualFileListener(myVirtualFileListener);
getResourceFolderManager(facet).removeListener(myResourceFolderListener);
}
}
@Override
public void addPropertyChangeListener(@NotNull PropertyChangeListener listener) {
}
@Override
public void removePropertyChangeListener(@NotNull PropertyChangeListener listener) {
}
@Nullable
@Override
public BackgroundEditorHighlighter getBackgroundHighlighter() {
return null;
}
@Nullable
@Override
public FileEditorLocation getCurrentLocation() {
return null;
}
@Nullable
@Override
public StructureViewBuilder getStructureViewBuilder() {
return null;
}
private void saveFile() throws IOException {
if (myModified) {
ByteArrayOutputStream stream = new ByteArrayOutputStream(INITIAL_FILE_BUFFER_SIZE);
new XMLWriter(stream).write(myNavigationModel);
myFile.setBinaryContent(stream.toByteArray());
myModified = false;
}
}
@Override
public void dispose() {
try {
saveFile();
}
catch (IOException e) {
LOG.error("Unexpected exception while saving navigation file", e);
}
myNavigationModel.getListeners().remove(myNavigationModelListener);
}
@Nullable
@Override
public <T> T getUserData(@NotNull Key<T> key) {
return myUserDataHolder.getUserData(key);
}
@Override
public <T> void putUserData(@NotNull Key<T> key, @Nullable T value) {
myUserDataHolder.putUserData(key, value);
}
}