// Copyright 2012 Google Inc. All Rights Reserved. // // 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.google.collide.client.code; import com.google.collide.client.util.Elements; import com.google.collide.client.util.PathUtil; import com.google.collide.dto.TreeNodeInfo; import com.google.collide.dto.NodeConflictDto.ConflictedPath; import com.google.collide.json.shared.JsonArray; import com.google.collide.mvp.CompositeView; import com.google.collide.mvp.UiComponent; import com.google.collide.shared.util.JsonCollections; import com.google.gwt.core.client.Scheduler; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import com.google.gwt.resources.client.DataResource; import elemental.css.CSSStyleDeclaration; import elemental.html.Element; /** * A trail of "breadcrumbs" representing the current file path and the current * code scope where the cursor is placed. If the cursor is inside a function * foo() {...} inside class bar, with the current file being * /www/widgets/widget.js, then the breadcrumb trail would look like: www > * widgets > widget.js > bar > foo. */ public class WorkspaceLocationBreadcrumbs extends UiComponent<WorkspaceLocationBreadcrumbs.View> { /** * Css resources for breadcrumbs. */ public interface Css extends CssResource { String breadcrumbBar(); String breadcrumbWrap(); String breadcrumbSlash(); String breadcrumb(); String start(); String hide(); /** Icons */ String directory(); String file(); String cls(); String function(); String field(); } /** * Client bundle for breadcrumbs. */ /* * TODO: Figure out a way to break the css reliance on data resources * then use the folder() and file() from base. */ public interface Resources extends ClientBundle { @Source("WorkspaceLocationBreadcrumbs.css") Css workspaceLocationBreadcrumbsCss(); @Source("com/google/collide/client/common/folder_breadcrumb.png") DataResource directoryImg(); @Source("com/google/collide/client/common/file_breadcrumb.png") DataResource fileImg(); @Source("path_slash.png") DataResource pathSlashImg(); } private static enum PathType { FOLDER, FILE, CLASS, FUNCTION, FIELD } /** * A class representing a single breadcrumb. */ static class Breadcrumb extends UiComponent<Breadcrumb.View> { /** * The view for a single breadcrumb. */ class View extends CompositeView<Void> { private Element breadcrumb; private final Css css; View(Resources res, String name, String iconClass) { css = res.workspaceLocationBreadcrumbsCss(); Element breadcrumbSlash = Elements.createSpanElement(css.breadcrumbSlash()); breadcrumbSlash.setTextContent("/"); breadcrumb = Elements.createSpanElement(css.breadcrumb()); breadcrumb.addClassName(iconClass); breadcrumb.setTextContent(name); Element breadcrumbWrap = Elements.createSpanElement(css.breadcrumbWrap()); breadcrumbWrap.appendChild(breadcrumbSlash); breadcrumbWrap.appendChild(breadcrumb); // Start hidden breadcrumb.addClassName(css.start()); setElement(breadcrumbWrap); } /** * Show by fading+sliding in from the left. */ void show(int startDelay, int duration) { CSSStyleDeclaration style = breadcrumb.getStyle(); // TODO: extract constants style.setProperty("-webkit-transition-duration", duration + "ms"); style.setProperty("-webkit-transition-delay", startDelay + "ms"); breadcrumb.removeClassName(css.start()); } /** * Hide by fading+sliding out to the left. */ void hide(int startDelay) { CSSStyleDeclaration style = breadcrumb.getStyle(); style.setProperty("-webkit-transition-delay", startDelay + "ms"); getElement().addClassName(css.hide()); } void detach() { getElement().removeFromParent(); } } private String name; private String iconClass; Breadcrumb(Resources res, String name, String iconClass) { View view = new View(res, name, iconClass); setView(view); this.name = name; this.iconClass = iconClass; } @Override public boolean equals(Object other) { if (other == this) { return true; } if (!(other instanceof Breadcrumb)) { return false; } Breadcrumb crumb = (Breadcrumb) other; return crumb.name.equals(name) && crumb.iconClass.equals(iconClass); } @Override public int hashCode() { int result = 17; result = 37 * result + name.hashCode(); result = 37 * result + iconClass.hashCode(); return result; } } /** * Number of milliseconds to delay between each breadcrumb being animated, to * create a path buildup/teardown effect. */ private static final int DELAY_SHOW_TIME_MIN = 100; private static final int DELAY_SHOW_TIME_MAX = 200; private static final int DELAY_HIDE_TIME = 150; private PathUtil currentFilePath = null; private JsonArray<Breadcrumb> currentPathCrumbs = JsonCollections.createArray(); /** * The view for the breadcrumbs. */ public static class View extends CompositeView<Void> { private final Css css; private final Resources res; View(Resources res) { this.res = res; this.css = res.workspaceLocationBreadcrumbsCss(); Element breadcrumbBar = Elements.createDivElement(css.breadcrumbBar()); setElement(breadcrumbBar); } public Breadcrumb createBreadcrumb(String name, PathType type) { String iconStyle = ""; switch (type) { case FOLDER: iconStyle = css.directory(); break; case FILE: iconStyle = css.file(); break; case CLASS: iconStyle = css.cls(); break; case FUNCTION: iconStyle = css.function(); break; case FIELD: iconStyle = css.field(); break; } return new Breadcrumb(res, name, iconStyle); } /** * First fade out all elements in remove, then fade in elements in add. */ void updateElements( final JsonArray<Breadcrumb.View> remove, final JsonArray<Breadcrumb.View> add) { for (int i = remove.size() - 1; i >= 0; i--) { remove.get(i).hide(0); } // Detach elements after animation finishes, then start the add animation Scheduler.get().scheduleFixedPeriod(new Scheduler.RepeatingCommand() { @Override public boolean execute() { for (int i = 0, n = remove.size(); i < n; i++) { remove.get(i).detach(); } int n = add.size(); if (n > 0) { for (int i = 0; i < n; i++) { getElement().appendChild(add.get(i).getElement()); } // Trigger a browser layout so CSS3 transitions fire add.get(0).getElement().getClientWidth(); int showDuration = Math.min(n * DELAY_SHOW_TIME_MIN, DELAY_SHOW_TIME_MAX) / n; for (int i = 0; i < n; i++) { add.get(i).show(showDuration * i, showDuration); } } return false; } }, DELAY_HIDE_TIME); } } /** * Calculate the difference between the currently displayed path and the new * file+scope paths and animate the change. */ void changeBreadcrumbPath(JsonArray<Breadcrumb> newPath) { // find path difference JsonArray<Breadcrumb.View> remove = JsonCollections.createArray(); JsonArray<Breadcrumb.View> add = JsonCollections.createArray(); /* * Walk from the existing beginning to the end, and once a difference is * found, mark the rest of the existing path to be removed and the rest of * the new path to be added. */ Breadcrumb newBreadcrumb; Breadcrumb curBreadcrumb; boolean isMatchingPath = true; int i = 0; for (int n = currentPathCrumbs.size(), m = newPath.size(); i < n || i < m; i++) { if (i < n && i < m) { // Check for path conflict, add and remove curBreadcrumb = currentPathCrumbs.get(i); newBreadcrumb = newPath.get(i); if (!isMatchingPath || !curBreadcrumb.equals(newBreadcrumb)) { isMatchingPath = false; remove.add(curBreadcrumb.getView()); add.add(newBreadcrumb.getView()); } else { // Equal, update new list with old Breadcrumb newPath.set(i, curBreadcrumb); } } else if (i >= n) { // No current path here, add new path add.add(newPath.get(i).getView()); } else if (i >= m) { // No new path here, remove current path remove.add(currentPathCrumbs.get(i).getView()); } } currentPathCrumbs = newPath; getView().updateElements(remove, add); } /** * Clears the current breadcrumb path. */ public void clearPath() { currentFilePath = null; changeBreadcrumbPath(JsonCollections.<Breadcrumb>createArray()); } /** * Returns the current breadcrumb path. */ public PathUtil getPath() { return currentFilePath; } /** * Sets the path that these breadcrumbs display. */ public void setPath(PathUtil newPath) { // TODO: Uncomment when onCursorLocationChanged is implemented. // editor.getSelection().getCursorListenerRegistrar().add(cursorListener); JsonArray<Breadcrumb> newFilePath = JsonCollections.createArray(); for (int i = 0, n = newPath.getPathComponentsCount(); i < n; i++) { if (i == n - 1) { newFilePath.add(getView().createBreadcrumb(newPath.getPathComponent(i), PathType.FILE)); } else { newFilePath.add(getView().createBreadcrumb(newPath.getPathComponent(i), PathType.FOLDER)); } } currentFilePath = newPath; changeBreadcrumbPath(newFilePath); } /** * Sets the path and type that these breadcrumbs display. */ public void setPath(ConflictedPath conflictedPath) { PathUtil newPath = new PathUtil(conflictedPath.getPath()); JsonArray<Breadcrumb> newFilePath = JsonCollections.createArray(); for (int i = 0, n = newPath.getPathComponentsCount(); i < n; i++) { if (i == n - 1 && conflictedPath.getNodeType() == TreeNodeInfo.FILE_TYPE) { newFilePath.add(getView().createBreadcrumb(newPath.getPathComponent(i), PathType.FILE)); } else { newFilePath.add(getView().createBreadcrumb(newPath.getPathComponent(i), PathType.FOLDER)); } } currentFilePath = newPath; changeBreadcrumbPath(newFilePath); } /* * TODO: Implement to show the current scope of the cursor. */ public void onCursorLocationChanged(int lineNumber, int column) { // TODO: Do this async to avoid cursor lag. JsonArray<Breadcrumb> newPath = currentPathCrumbs.copy(); // JsoArray<String> scope = autocompleter.getLocationScope(lineNumber, // column); newPath.add(getView().createBreadcrumb("Line " + lineNumber, PathType.CLASS)); newPath.add(getView().createBreadcrumb("Column " + column, PathType.FUNCTION)); changeBreadcrumbPath(newPath); } }