// 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.document.linedimensions.LineDimensionsUtils;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.editor.ViewportModel;
import com.google.collide.client.editor.Editor.KeyListener;
import com.google.collide.client.editor.Editor.NativeKeyUpListener;
import com.google.collide.client.editor.Editor.ReadOnlyListener;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.client.util.BrowserUtils;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.SignalEventUtils;
import com.google.collide.client.util.logging.Log;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.DocumentMutator;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.Position;
import com.google.collide.shared.document.util.LineUtils;
import com.google.collide.shared.util.ListenerManager;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.collide.shared.util.TextUtils;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import org.waveprotocol.wave.client.common.util.SignalEvent;
import elemental.css.CSSStyleDeclaration;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.events.TextEvent;
import elemental.html.Element;
import elemental.html.TextAreaElement;
/**
* Controller for taking input from the user. This manages an offscreen textarea
* that receives the user's entered text.
*
* The lifecycle of this class is tied to the editor that owns it.
*
*/
public class InputController {
// TODO: move to elemental
private static final String EVENT_TEXTINPUT = "textInput";
final InputScheme nativeScheme;
final InputScheme vimScheme;
private Document document;
private Editor editor;
private DocumentMutator editorDocumentMutator;
private final TextAreaElement inputElement;
private InputScheme activeInputScheme = null;
private final ListenerManager<KeyListener> keyListenerManager = ListenerManager.create();
private final ListenerManager<NativeKeyUpListener> nativeKeyUpListenerManager = ListenerManager
.create();
private SelectionModel selection;
private ViewportModel viewport;
private final RootActionExecutor actionExecutor;
public InputController() {
inputElement = createInputElement();
actionExecutor = new RootActionExecutor();
nativeScheme = new DefaultScheme(this);
vimScheme = new VimScheme(this);
}
public Document getDocument() {
return document;
}
public Editor getEditor() {
return editor;
}
public DocumentMutator getEditorDocumentMutator() {
return editorDocumentMutator;
}
public Element getInputElement() {
return inputElement;
}
public String getInputText() {
return inputElement.getValue();
}
public ListenerRegistrar<KeyListener> getKeyListenerRegistrar() {
return keyListenerManager;
}
public ListenerRegistrar<NativeKeyUpListener> getNativeKeyUpListenerRegistrar() {
return nativeKeyUpListenerManager;
}
public SelectionModel getSelection() {
return selection;
}
public void handleDocumentChanged(Document document, SelectionModel selection,
ViewportModel viewport) {
this.document = document;
this.selection = selection;
this.viewport = viewport;
}
public void initializeFromEditor(Editor editor, DocumentMutator editorDocumentMutator) {
this.editor = editor;
this.editorDocumentMutator = editorDocumentMutator;
editor.getReadOnlyListenerRegistrar().add(new ReadOnlyListener() {
@Override
public void onReadOnlyChanged(boolean isReadOnly) {
handleReadOnlyChanged(isReadOnly);
}
});
handleReadOnlyChanged(editor.isReadOnly());
}
private void handleReadOnlyChanged(boolean isReadOnly) {
if (isReadOnly) {
setActiveInputScheme(new ReadOnlyScheme(this));
} else {
setActiveInputScheme(nativeScheme);
}
}
public void setActiveInputScheme(InputScheme inputScheme) {
if (this.activeInputScheme != null) {
this.activeInputScheme.teardown();
}
this.activeInputScheme = inputScheme;
this.activeInputScheme.setup();
}
public void setInputText(String text) {
inputElement.setValue(text);
}
public void setSelection(SelectionModel selection) {
this.selection = selection;
}
boolean dispatchKeyPress(final SignalEvent signalEvent) {
class KeyDispatcher implements Dispatcher<KeyListener> {
boolean handled;
@Override
public void dispatch(KeyListener listener) {
handled |= listener.onKeyPress(signalEvent);
}
}
KeyDispatcher keyDispatcher = new KeyDispatcher();
keyListenerManager.dispatch(keyDispatcher);
return keyDispatcher.handled;
}
boolean dispatchKeyUp(final Event event) {
class NativeKeyUpDispatcher implements Dispatcher<Editor.NativeKeyUpListener> {
boolean handled;
@Override
public void dispatch(NativeKeyUpListener listener) {
handled |= listener.onNativeKeyUp(event);
}
}
NativeKeyUpDispatcher nativeKeyUpDispatcher = new NativeKeyUpDispatcher();
nativeKeyUpListenerManager.dispatch(nativeKeyUpDispatcher);
return nativeKeyUpDispatcher.handled;
}
private TextAreaElement createInputElement() {
final TextAreaElement inputElement = Elements.createTextAreaElement();
// Ensure it is offscreen
inputElement.getStyle().setPosition(CSSStyleDeclaration.Position.ABSOLUTE);
inputElement.getStyle().setLeft("-100000px");
inputElement.getStyle().setTop("0");
inputElement.getStyle().setHeight("1px");
inputElement.getStyle().setWidth("1px");
/*
* Firefox doesn't seem to respect just the NOWRAP value, so we need to set
* the legacy wrap attribute.
*/
inputElement.setAttribute("wrap", "off");
// Attach listeners
/*
* For text events, call inputHandler.handleInput(event, text) if the text
* entered was > 1 character -> from a paste event. This gets fed directly
* into the document. Single keypresses all get captured by signalEventListener
* and passed through the shortcut system.
*
* TODO: This isn't actually true, there could be paste events
* of only one character. Change this to check if the event was a clipboard
* event.
*/
inputElement.addEventListener(EVENT_TEXTINPUT, new EventListener() {
@Override
public void handleEvent(Event event) {
/*
* TODO: figure out best event to listen to. Tried "input",
* but see http://code.google.com/p/chromium/issues/detail?id=76516
*/
String text = ((TextEvent) event).getData();
if (text.length() <= 1) {
return;
}
setInputText("");
activeInputScheme.handleEvent(SignalEventUtils.create(event), text);
}
}, false);
if (BrowserUtils.isFirefox()) {
inputElement.addEventListener(Event.INPUT, new EventListener() {
@Override
public void handleEvent(Event event) {
/*
* TODO: FF doesn't support textInput, and Chrome's input
* is buggy.
*/
String text = getInputText();
if (text.length() <= 1) {
return;
}
setInputText("");
activeInputScheme.handleEvent(SignalEventUtils.create(event), text);
event.preventDefault();
event.stopPropagation();
}
}, false);
}
EventListener signalEventListener = new EventListener() {
@Override
public void handleEvent(Event event) {
SignalEvent signalEvent = SignalEventUtils.create(event);
if (signalEvent != null) {
processSignalEvent(signalEvent);
} else if ("keyup".equals(event.getType())) {
boolean handled = dispatchKeyUp(event);
if (handled) {
// Prevent any browser handling.
event.preventDefault();
event.stopPropagation();
}
}
}
};
/*
* Attach to all of key events, and the SignalEvent logic will filter
* appropriately
*/
inputElement.addEventListener(Event.KEYDOWN, signalEventListener, false);
inputElement.addEventListener(Event.KEYPRESS, signalEventListener, false);
inputElement.addEventListener(Event.KEYUP, signalEventListener, false);
inputElement.addEventListener(Event.COPY, signalEventListener, false);
inputElement.addEventListener(Event.PASTE, signalEventListener, false);
inputElement.addEventListener(Event.CUT, signalEventListener, false);
return inputElement;
}
@VisibleForTesting
public void processSignalEvent(SignalEvent signalEvent) {
boolean handled = dispatchKeyPress(signalEvent);
if (!handled) {
if (signalEvent.isCopyEvent() || signalEvent.isCutEvent()) {
prepareForCopy();
if (signalEvent.isCutEvent() && selection.hasSelection()) {
selection.deleteSelection(editorDocumentMutator);
}
// These events are special cased, nothing else should happen.
return;
}
/*
* Send all keypresses through here.
*/
try {
handled = activeInputScheme.handleEvent(signalEvent, "");
} catch (Throwable t) {
Log.error(getClass(), t);
}
}
if (handled) {
// Prevent any browser handling.
signalEvent.preventDefault();
signalEvent.stopPropagation();
setInputText("");
}
}
public void prepareForCopy() {
if (!selection.hasSelection()) {
// TODO: Discuss Ctrl-X feature.
return;
}
Position[] selectionRange = selection.getSelectionRange(true);
String selectionText = LineUtils.getText(
selectionRange[0].getLine(), selectionRange[0].getColumn(),
selectionRange[1].getLine(), selectionRange[1].getColumn());
setInputText(selectionText);
inputElement.select();
Scheduler.get().scheduleDeferred(new ScheduledCommand() {
@Override
public void execute() {
/*
* The text has been copied by now, so clear it (if the text was large,
* it would cause slow layout)
*/
setInputText("");
}
});
}
/**
* Add a tab character to the beginning of each line in the current selection,
* or at the current cursor position if no text is selected.
*/
// TODO: This should probably be a setting, tabs or spaces
public void handleTab() {
if (selection.hasMultilineSelection()) {
indentSelection();
} else {
getEditorDocumentMutator().insertText(selection.getCursorLine(),
selection.getCursorLineNumber(), selection.getCursorColumn(),
LineDimensionsUtils.getTabAsSpaces());
}
}
public void indentSelection() {
selection.adjustSelectionIndentation(
editorDocumentMutator, LineDimensionsUtils.getTabAsSpaces(), true);
}
/**
* Removes the indentation from the beginning of each line of a multiline
* selection.
*/
public void dedentSelection() {
selection.adjustSelectionIndentation(
editorDocumentMutator, LineDimensionsUtils.getTabAsSpaces(), false);
}
/**
* Delete a character around the current cursor, and take care of joining lines
* together if the delete removes a newline. This is used to implement backspace
* and delete, depending upon the afterCursor argument.
*
* @param afterCursor if true, delete the character to the right of the cursor
*/
public void deleteCharacter(boolean afterCursor) {
if (tryDeleteSelection()) {
return;
}
Line cursorLine = selection.getCursorLine();
int cursorLineNumber = selection.getCursorLineNumber();
int deleteColumn = !afterCursor ? selection.getCursorColumn() - 1 : selection.getCursorColumn();
if (cursorLine.hasColumn(deleteColumn)) {
getEditorDocumentMutator().deleteText(cursorLine, cursorLineNumber, deleteColumn, 1);
} else if (deleteColumn < 0 && cursorLine.getPreviousLine() != null) {
// Join the lines
Line previousLine = cursorLine.getPreviousLine();
getEditorDocumentMutator().deleteText(previousLine, cursorLineNumber - 1,
previousLine.getText().length() - 1, 1);
}
}
public void deleteWord(boolean afterCursor) {
if (tryDeleteSelection()) {
return;
}
Line cursorLine = selection.getCursorLine();
int cursorColumn = selection.getCursorColumn();
boolean mergeWithPreviousLine = cursorColumn == 0 && !afterCursor;
boolean mergeWithNextLine = cursorColumn == cursorLine.length() - 1 && afterCursor;
if (mergeWithPreviousLine || mergeWithNextLine) {
// Re-use delete character logic
deleteCharacter(afterCursor);
return;
}
int otherColumn =
afterCursor ? TextUtils.findNextWord(cursorLine.getText(), cursorColumn, true) : TextUtils
.findPreviousWord(cursorLine.getText(), cursorColumn, false);
editorDocumentMutator.deleteText(cursorLine, Math.min(otherColumn, cursorColumn),
Math.abs(otherColumn - cursorColumn));
}
private boolean tryDeleteSelection() {
if (selection.hasSelection()) {
selection.deleteSelection(editorDocumentMutator);
return true;
} else {
return false;
}
}
ViewportModel getViewportModel() {
return viewport;
}
public RootActionExecutor getActionExecutor() {
return actionExecutor;
}
}