// 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.editor.input; import com.google.collide.client.util.SignalEventUtils; import com.google.collide.client.util.input.CharCodeWithModifiers; import com.google.collide.client.util.input.KeyCodeMap; import org.waveprotocol.wave.client.common.util.SignalEvent; import elemental.events.Event; import elemental.js.util.JsArrayOfInt; import elemental.js.util.JsMapFromIntTo; /** * Class that represents an editor mode for handling input, such as vi's insert * mode. * * <p>Each mode controls a collection of {@link Shortcut}s and fires them * based upon any input via {@link Shortcut#event}. */ public abstract class InputMode { /** * Effect of a SignalEvent. */ public enum EventResult { /** * Event would directly trigger a StreamShortcut callback. */ STREAM_CALLBACK, /** * Event is part of one or more StreamShortcuts, but has not been completely * typed. */ STREAM_PART, /** * Event would directly trigger an EventShortcut callback. */ EVENT_CALLBACK, /** * Event would not trigger an Event, or was part/all of StreamShortcut */ NONE } /** * Manager of collection of shortcuts for this class and the buffer of any * ongoing shortcut stream of keys. */ public class ShortcutController { /** * Generic Node class. * * Cannot be nested inside PartialTrie * (<a href="http://code.google.com/p/google-web-toolkit/issues/detail?id=5483">link</a>). */ private class Node<T> { T value; JsMapFromIntTo<Node<T>> next; Node() { next = JsMapFromIntTo.create(); } } //TODO: Move this functionality to AbstractTrie. /** * Basic trie class, supporting only put and get, plus a feedback function * to check if a string is along the path to a valid trie entry. * * @see #alongPath */ private class PartialTrie<T> { private Node<T> root; PartialTrie() { root = new Node<T>(); } /** * Inserts a new value T into the trie. * * @return T previous value at prefix, or null if there was no old entry */ T put(JsArrayOfInt prefix, T value) { Node<T> current = root; for (int i = 0, n = prefix.length(); i < n; i++) { int index = prefix.get(i); Node<T> next = current.next.get(index); if (next == null) { // this branch doesn't exist yet next = new Node<T>(); current.next.put(index, next); current = next; } } T old = current.value; current.value = value; return old; } /** * Returns 0 if seq is along the path to one or more entries. * * <p>For example, nearestValue("app") would return 0 for a trie with * values "apples", "apple", "orange". "apple" has 0 characters to get * to "apple". * * @return {@code 1} for direct match, {@code 0} for path match, * {@code -1} for no match */ int alongPath(JsArrayOfInt seq) { Node<T> current = root; for (int i = 0, n = seq.length(); i < n; i++) { int index = seq.get(i); current = current.next.get(index); if (current == null) { return -1; // off the end of the trie, no match } } // If we get here, current is along the path to one or more valid // entries if (current.value != null) { return 1; } else { return 0; } } /** * Returns the value T stored at exactly this location in the trie. * * @return T */ T get(JsArrayOfInt seq) { Node<T> current = root; for (int i = 0, n = seq.length(); i < n; i++) { int index = seq.get(i); current = current.next.get(index); if (current == null) { return null; } } return current.value; } } /** * Buffer of the current input stream building to a shortcut. * * <p>String is represented by an array of UTF-16 integers. * * <p>This will be appended to when the input matches a prefix of one * or more {@link StreamShortcut}s. Used to match against a PrefixTrie. */ JsArrayOfInt streamBuffer; PartialTrie<StreamShortcut> streamTrie; /** * A map of {@link EventShortcut}s from event hash to shortcut object. */ JsMapFromIntTo<EventShortcut> eventShortcuts; public ShortcutController() { eventShortcuts = JsMapFromIntTo.create(); streamTrie = new PartialTrie<StreamShortcut>(); streamBuffer = JsArrayOfInt.create(); } /** * Adds an event shortcut to the event shortcut map. */ public void addShortcut(EventShortcut event) { eventShortcuts.put(event.getKeyDigest(), event); } /** * Adds a stream shortcut to the stream shortcut trie. */ public void addShortcut(StreamShortcut stream) { streamTrie.put(stream.getActivationStream(), stream); } /** * Clears any internal state (streamBuffer value), * after shortcut was triggered. */ public void reset() { streamBuffer.setLength(0); } /** * Returns the shortcut associated with this event. */ private Shortcut findEventShortcut(SignalEvent event) { int keyDigest = CharCodeWithModifiers.computeKeyDigest(event); if (eventShortcuts.hasKey(keyDigest)) { return eventShortcuts.get(keyDigest); } else { return null; } } /** * Searches the trie using streamBuffer for an exact * {@link StreamShortcut} match. * * @return {@link StreamShortcut} if found, else {@code null} */ private Shortcut findStreamShortcut() { return streamTrie.get(streamBuffer); } /** * Adds the keycode of the event to the end of the stream buffer. */ private void addToStreamBuffer(SignalEvent event) { streamBuffer.push(KeyCodeMap.getKeyFromEvent(event)); } /** * Deletes the last character from the stream buffer (backspace). */ public void deleteLastCharFromStreamBuffer() { streamBuffer.setLength(streamBuffer.length() - 1); } /** * Tests if the event should be "captured". * * <p>Event should be captured if it either:<ul> * <li>directly fires a callback or * <li>is part of a StreamCallback that hasn't been fully typed yet * </ul> * * @return {@link EventResult} that this event would cause */ public EventResult testEventEffect(SignalEvent event) { // Letters above U+FFFF will wrap around, so they aren't supported in // shortcuts. if (event.getKeyCode() > 0xFFFF) { return EventResult.NONE; } // Try EventShortcut. int keyDigest = CharCodeWithModifiers.computeKeyDigest(event); if (eventShortcuts.hasKey(keyDigest)) { return EventResult.EVENT_CALLBACK; } // Then try StreamShortcut. addToStreamBuffer(event); int streamResult = streamTrie.alongPath(streamBuffer); // Take off event keycode - it was added only to search trie. deleteLastCharFromStreamBuffer(); if (streamResult == 1) { // Exact match. return EventResult.STREAM_CALLBACK; } else if (streamResult == 0) { // Partial match. return EventResult.STREAM_PART; } // No effect. return EventResult.NONE; } } private ShortcutController shortcutController; private InputScheme scheme = null; public InputMode() { shortcutController = new ShortcutController(); } /** * Preforms mode-specific setup (such as adding a new overlay to * display the current search term as the user types). */ public abstract void setup(); /** * Removes document changes made in {@link InputMode#setup()}. */ public abstract void teardown(); /** * Implements default behavior when no shortcut matches the input event. * * <p>Include the text captured from the hidden input field. * * @param character - 0 for no printable character * @return {@code true} to prevent default action in browser */ public abstract boolean onDefaultInput(SignalEvent signal, char character); /** * Takes action after user has inserted more than one character of text. * * @param text - more than one character * @return boolean True to prevent default action in browser */ public abstract boolean onDefaultPaste(SignalEvent signal, String text); void setScheme(InputScheme scheme) { this.scheme = scheme; } public InputScheme getScheme() { return this.scheme; } /** * Binds specified key to named action. */ public void bindAction(String actionName, int modifiers, int charCode) { shortcutController.addShortcut(new ActionShortcut(modifiers, charCode, actionName)); } /** * Adds this event shortcut to the shortcut controller. */ public void addShortcut(EventShortcut shortcut) { shortcutController.addShortcut(shortcut); } public void addShortcut(StreamShortcut shortcut) { shortcutController.addShortcut(shortcut); } /** * Checks if this event should fire any shortcuts. * * <p>There is not matching events, fires the defaultInput function. * * @return {@code true} if default browser behavior should be prevented */ public boolean handleEvent(SignalEvent event, String text) { if (event.isPasteEvent()) { String pasteContents = SignalEventUtils.getPasteContents((Event) event.asEvent()); if (pasteContents != null) { return onDefaultPaste(event, pasteContents); } } // If one character was entered, send it through the shortcut system, else // think of it as a paste. if (text.length() > 1) { return onDefaultPaste(event, text); } else { Shortcut eventShortcut = null; EventResult result = shortcutController.testEventEffect(event); if (result == EventResult.EVENT_CALLBACK) { eventShortcut = shortcutController.findEventShortcut(event); } if (result == EventResult.NONE) { shortcutController.reset(); char character = 0; if (text.length() == 1) { character = text.charAt(0); } return onDefaultInput(event, character); } if (result == EventResult.STREAM_CALLBACK || result == EventResult.STREAM_PART) { // Always add to the buffer for either of these. shortcutController.addToStreamBuffer(event); if (result == EventResult.STREAM_CALLBACK) { eventShortcut = shortcutController.findStreamShortcut(); } else { // STREAM_PART return true; // Always prevent default when adding to stream buffer. } } // Tell the shortcut controller that a shortcut was fired so it can reset // state. shortcutController.reset(); boolean returnValue = eventShortcut.event(this.scheme, event); // Only fire if the event is blocked. if (returnValue) { this.scheme.handleShortcutCalled(); } return returnValue; } } }