org.waveprotocol.wave.client.editor.sugg.InteractiveSuggestionsManager.java Source code

Java tutorial

Introduction

Here is the source code for org.waveprotocol.wave.client.editor.sugg.InteractiveSuggestionsManager.java

Source

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.waveprotocol.wave.client.editor.sugg;

import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Style;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.Style.Visibility;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.ui.impl.FocusImpl;
import org.waveprotocol.wave.client.common.util.LinkedPruningSequenceMap;
import org.waveprotocol.wave.client.common.util.PruningSequenceMap;
import org.waveprotocol.wave.client.common.util.SequenceElement;
import org.waveprotocol.wave.client.editor.EditorStaticDeps;
import org.waveprotocol.wave.client.editor.content.ContentElement;
import org.waveprotocol.wave.client.editor.content.ContentNode;
import org.waveprotocol.wave.client.editor.selection.content.SelectionHelper;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.scheduler.SchedulerTimerService;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.client.widget.popup.PopupEventListener;
import org.waveprotocol.wave.client.widget.popup.PopupEventSourcer;
import org.waveprotocol.wave.client.widget.popup.RelativePopupPositioner;
import org.waveprotocol.wave.client.widget.popup.UniversalPopup;
import org.waveprotocol.wave.model.document.util.FocusedRange;
import org.waveprotocol.wave.model.document.util.Point;

/**
 * Interactive implementation with real UI.
 *
 * TODO(user): Add a unit test for this class.
 *
 * @author danilatos@google.com (Daniel Danilatos)
 */
public class InteractiveSuggestionsManager
        implements SuggestionsManager, RelativePopupPositioner, PopupEventListener {

    /**
     * Handles events fired by SuggestionMenu
     */
    public interface SuggestionMenuHandler {
        void handleItemSelected();

        void handleLeftRight(boolean b);

        void beforeItemClicked();

        void handleMouseOut();

        void handleMouseOver();
    }

    private final SuggestionMenuHandler handler = new SuggestionMenuHandler() {
        @Override
        public void handleItemSelected() {
            // NOTE(user): The previous behaviour here moves to the next item, but we
            // don't want to do that as it disrupts the user flow. Perhaps enable it
            // for rare cases.
            // moveToNextItem();
            popupCloser.closeImmediately();
        }

        /**
         * Resets current to the next (or previous, if isRight is false) element.
         * Wraps to the beginning (or end), unless there is only one element in the
         * sequence, in which case current is set to null.
         */
        @Override
        public void handleLeftRight(boolean isRight) {
            SequenceElement<HasSuggestions> newCurrent = isRight ? current.getNext() : current.getPrev();
            if (newCurrent == current) {
                newCurrent = null;
            }
            setCurrent(newCurrent);
        }

        @Override
        public void beforeItemClicked() {
            if (savedSelection != null) {
                try {
                    selectionHelper.setSelectionRange(savedSelection);
                } finally {
                    savedSelection = null;
                }
            }
        }

        @Override
        public void handleMouseOut() {
            popupCloser.scheduleClose(null);
        }

        @Override
        public void handleMouseOver() {
            popupCloser.cancelScheduledClose();
        }
    };

    /**
     * Manages the scheduling of hiding the popup. The asynchronous logic enables
     * us to close the menu a short period after the user mouses off the menu,
     * but to cancel the scheduled close if the mouse returns onto the menu.
     */
    private final class PopupCloser {
        private final TimerService timerService;

        private final Scheduler.Task task = new Scheduler.Task() {
            @Override
            public void execute() {
                closeImmediately();
            }
        };

        private Command callback;

        private PopupCloser(TimerService timerService) {
            this.timerService = timerService;
            this.callback = null;
        }

        private void closeImmediately() {
            popup.hide();
            if (callback != null) {
                callback.execute();
                callback = null;
            }
        }

        private void scheduleClose(Command callback) {
            this.callback = callback;
            timerService.scheduleDelayed(task, closeSuggestionMenuDelayMs);
        }

        private void cancelScheduledClose() {
            timerService.cancel(task);
            callback = null;
        }
    }

    /** Singleton suggestion menu per manager */
    private final SuggestionMenu menu = new SuggestionMenu(handler);

    private final UniversalPopup popup;

    /** The popup appears relative to this element. */
    private Element popupAnchor;

    // TODO(danilatos): Implement a binary tree implementation instead of LL.
    private final PruningSequenceMap<ContentNode, HasSuggestions> suggestables = LinkedPruningSequenceMap
            .<ContentNode, HasSuggestions>create();

    private final SelectionHelper selectionHelper;

    private FocusedRange savedSelection = null;

    private SequenceElement<HasSuggestions> current = null;

    private PopupCloser popupCloser = new PopupCloser(new SchedulerTimerService(SchedulerInstance.get()));

    private final int closeSuggestionMenuDelayMs;

    /** Constructor */
    public InteractiveSuggestionsManager(SelectionHelper selectionHelper, int closeSuggestionMenuDelayMs) {
        popup = EditorStaticDeps.createPopup(null, this, true, false, menu, this);
        this.closeSuggestionMenuDelayMs = closeSuggestionMenuDelayMs;
        this.selectionHelper = selectionHelper;
    }

    @Override
    public void clear() {
        suggestables.clear();
    }

    @Override
    public void registerElement(HasSuggestions element) {
        suggestables.put(element.getSuggestionElement(), element);
    }

    @Override
    public boolean showSuggestionsNearestTo(Point<ContentNode> location) {
        popupCloser.cancelScheduledClose();
        SequenceElement<HasSuggestions> newCurrent = suggestables.findBefore(location.getContainer());

        if (newCurrent == null) {
            // If null, cursor is before the first one, try the first one.
            newCurrent = suggestables.getFirst();
        } else if (!suggestables.isLast(newCurrent)) {
            // If it's not null and not the last one, we are between "current" and
            // the next suggestable. see which is closer.
            newCurrent.getNext();

            // TODO(danilatos):
            // if (pixel distance to next < dist to current) { current = next; }
        }

        if (newCurrent == null) {
            return false;
        }

        setCurrent(getFromKeyboard(newCurrent, false));
        return newCurrent != null;
    }

    @Override
    public void showSuggestionsFor(HasSuggestions suggestable) {
        popupCloser.cancelScheduledClose();
        ContentElement element = suggestable.getSuggestionElement();
        // NOTE(user): If content is not attached, then at the moment, we don't
        // bring up any suggestions. In the future, we may decide to look for other
        // suggestions that are sufficiently near.
        if (element.isContentAttached()) {
            setCurrent(suggestables.getElement(element));
        }
    }

    /**
     * Schedule the closing of the suggestions menu. The closing may not actually
     * happen if the user mouses onto the menu before it is scheduled to close.
     */
    @Override
    public void hideSuggestions(Command callback) {
        popupCloser.scheduleClose(callback);
    }

    /**
     * Logic for setting the current suggestiable, given a seq element.
     * The flow of these related methods looks like this:
     *
     * {@code
     * showSuggestionsFor        -->  setCurrent  --> showSuggestionsForInner
     * showSuggestionsNearestTo             ^
     *          ^                           |
     * Outside _|            Local Methods _|
     * }
     */
    private void setCurrent(SequenceElement<HasSuggestions> newCurrent) {
        //logic for hiding old one
        boolean alreadyShown = false;
        if (current != null) {
            if (newCurrent == null) {
                popupCloser.closeImmediately();
            } else {
                changeAwayFromCurrent();
            }
            alreadyShown = true;
        }

        // logic for setting up and showing new one
        if (newCurrent != null) {

            current = newCurrent;

            HasSuggestions suggestable = current.value();
            menu.clearItems();
            suggestable.populateSuggestionMenu(menu);
            suggestable.handleShowSuggestionMenu();
            // HACK(danilatos): I had to patch MenuBar to make this method public.
            // Getting more and more tempting to write own menu class...
            // Calling this makes the first item in the menu selected by default,
            // so just pressing enter will choose it.
            menu.moveSelectionDown();
            ContentElement element = suggestable.getSuggestionElement();

            popupAnchor = element.getImplNodelet();
            // If savedSelection is null, it should be the first time we are showing a popup (not moving
            // around). So, we save the selection because it becomes null later when we lose focus,
            // at least in IE.
            if (savedSelection == null) {
                savedSelection = selectionHelper.getSelectionRange();
            }

            if (alreadyShown) {
                popup.move();
            } else {
                popup.show();
            }
        }
    }

    private SequenceElement<HasSuggestions> getFromKeyboard(SequenceElement<HasSuggestions> el,
            boolean rightWardsFirst) {
        SequenceElement<HasSuggestions> found = getFromKeyboardSpecifiedDirectionOnly(el, rightWardsFirst);
        if (found == null) {
            getFromKeyboardSpecifiedDirectionOnly(el, !rightWardsFirst);
        }
        return found;
    }

    private SequenceElement<HasSuggestions> getFromKeyboardSpecifiedDirectionOnly(
            SequenceElement<HasSuggestions> el, boolean rightWards) {
        assert el != null;

        SequenceElement<HasSuggestions> start = el;

        // NOTE(user): SequenceElement.getNext() and getPrev() never returns null if
        // there are any elements in the SequenceMap at all. These methods return null
        // if the getNext()/getPrev() returns the original element.
        SequenceElement<HasSuggestions> seen = null;
        while (true) {
            // Went through the entire list, didn't find anything we
            // should show suggestions for.
            if (el == seen) {
                el = null;
                break;
            }

            assert el != null : "Sequence element contract does't allow this.";

            if (el.value().isAccessibleFromKeyboard()) {
                break;
            }

            if (seen == null) {
                seen = el;
            }

            if (rightWards) {
                el = el.getNext();
            } else {
                el = el.getPrev();
            }
        }

        return el;
    }

    @Override
    public void onHide(PopupEventSourcer source) {
        // Restore selection that we lost
        // TODO(danilatos): Transform this against operations that came in the meantime
        try {
            if (savedSelection != null) {
                selectionHelper.setSelectionRange(savedSelection);
            }
        } finally {
            savedSelection = null;
            if (current != null) {
                changeAwayFromCurrent();
                current = null;
            }
        }
    }

    @Override
    public void onShow(PopupEventSourcer source) {
        // NOTE(user): Clear selection so that it doesn't get forcibly restored
        // when applying operations. In Firefox, that would take focus away from the
        // suggestion menu.
        selectionHelper.clearSelection();
        FocusImpl.getFocusImplForPanel().focus(menu.getElement());
    }

    @Override
    public void setPopupPositionAndMakeVisible(Element reference, final Element popup) {
        Style popupStyle = popup.getStyle();

        // TODO(danilatos): Do something more intelligent than arbitrary constants (which might be
        // susceptible to font size changes, etc)
        popupStyle.setLeft(popupAnchor.getAbsoluteLeft() - popup.getOffsetWidth() + 26, Unit.PX);
        popupStyle.setTop(popupAnchor.getAbsoluteBottom() + 5, Unit.PX);

        popupStyle.setVisibility(Visibility.VISIBLE);
    }

    private void changeAwayFromCurrent() {
        current.value().handleHideSuggestionMenu();
    }
}