blob: b00d121dba328ddef448558315e220bcaacb14ef [file] [log] [blame]
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
library trydart.interaction_manager;
import 'dart:html';
import 'dart:convert' show
JSON;
import 'dart:math' show
max,
min;
import 'package:compiler/implementation/scanner/scannerlib.dart'
show
EOF_TOKEN,
StringScanner,
Token;
import 'package:compiler/implementation/source_file.dart' show
StringSourceFile;
import 'compilation.dart' show
scheduleCompilation;
import 'ui.dart' show
currentTheme,
hackDiv,
inputPre,
observer,
outputDiv;
import 'decoration.dart' show
CodeCompletionDecoration,
Decoration,
DiagnosticDecoration,
error,
info,
warning;
import 'editor.dart' as editor;
import 'mock.dart' as mock;
import 'settings.dart' as settings;
/**
* UI interaction manager for the entire application.
*/
abstract class InteractionManager {
// Design note: All UI interactions go through one instance of this
// class. This is by design.
//
// Simplicity in UI is in the eye of the beholder, not the implementor. Great
// 'natural UI' is usually achieved with substantial implementation
// complexity that doesn't modularise well and has nasty complicated state
// dependencies.
//
// In rare cases, some UI components can be independent of this state
// machine. For example, animation and auto-save loops.
// Implementation note: The state machine is actually implemented by
// [InteractionContext], this class represents public event handlers.
factory InteractionManager() => new InteractionContext();
InteractionManager.internal();
void onInput(Event event);
void onKeyUp(KeyboardEvent event);
void onMutation(List<MutationRecord> mutations, MutationObserver observer);
void onSelectionChange(Event event);
}
/**
* State machine for UI interactions.
*/
class InteractionContext extends InteractionManager {
InteractionState state;
InteractionContext()
: super.internal() {
state = new InitialState(this);
}
void onInput(Event event) => state.onInput(event);
void onKeyUp(KeyboardEvent event) => state.onKeyUp(event);
void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
return state.onMutation(mutations, observer);
}
void onSelectionChange(Event event) => state.onSelectionChange(event);
}
abstract class InteractionState implements InteractionManager {
void onStateChanged(InteractionState previous) {
print('State change ${previous.runtimeType} -> ${runtimeType}.');
}
}
class InitialState extends InteractionState {
final InteractionContext context;
bool requestCodeCompletion = false;
InitialState(this.context);
void set state(InteractionState state) {
InteractionState previous = context.state;
if (previous != state) {
context.state = state;
state.onStateChanged(previous);
}
}
void onInput(Event event) {
state = new PendingInputState(context);
}
void onKeyUp(KeyboardEvent event) {
if (computeHasModifier(event)) {
print('onKeyUp (modified)');
onModifiedKeyUp(event);
} else {
print('onKeyUp (unmodified)');
onUnmodifiedKeyUp(event);
}
}
void onModifiedKeyUp(KeyboardEvent event) {
}
void onUnmodifiedKeyUp(KeyboardEvent event) {
switch (event.keyCode) {
case KeyCode.ENTER: {
event.preventDefault();
Selection selection = window.getSelection();
if (selection.isCollapsed && selection.anchorNode is Text) {
Text text = selection.anchorNode;
int offset = selection.anchorOffset;
text.insertData(offset, '\n');
selection.collapse(text, offset + 1);
}
break;
}
}
// editor.scheduleRemoveCodeCompletion();
// This is a hack to get Safari (iOS) to send mutation events on
// contenteditable.
// TODO(ahe): Move to onInput?
var newDiv = new DivElement();
hackDiv.replaceWith(newDiv);
hackDiv = newDiv;
}
// TODO(ahe): This method should be cleaned up. It is too large.
void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
print('onMutation');
for (String query in const ['a.diagnostic>span',
'.dart-code-completion',
'.hazed-suggestion']) {
for (Element element in inputPre.querySelectorAll(query)) {
element.remove();
}
}
// Discard clean-up mutations.
observer.takeRecords();
Selection selection = window.getSelection();
while (!mutations.isEmpty) {
for (MutationRecord record in mutations) {
String type = record.type;
switch (type) {
case 'characterData':
bool hasSelection = false;
int offset = selection.anchorOffset;
if (selection.isCollapsed &&
selection.anchorNode == record.target) {
hasSelection = true;
}
var parent = record.target.parentNode;
if (parent != inputPre) {
editor.inlineChildren(parent);
}
if (hasSelection) {
selection.collapse(record.target, offset);
}
break;
default:
if (!record.addedNodes.isEmpty) {
for (var node in record.addedNodes) {
if (node.nodeType != Node.ELEMENT_NODE) continue;
if (node is BRElement) {
if (selection.anchorNode != node) {
node.replaceWith(new Text('\n'));
}
} else {
var parent = node.parentNode;
if (parent == null) continue;
var nodes = new List.from(node.nodes);
var style = node.getComputedStyle();
if (style.display != 'inline') {
var previous = node.previousNode;
if (previous is Text) {
previous.appendData('\n');
} else {
parent.insertBefore(new Text('\n'), node);
}
}
for (Node child in nodes) {
child.remove();
parent.insertBefore(child, node);
}
node.remove();
}
}
}
}
}
mutations = observer.takeRecords();
}
if (!inputPre.nodes.isEmpty && inputPre.nodes.last is Text) {
Text text = inputPre.nodes.last;
if (!text.text.endsWith('\n')) {
text.appendData('\n');
}
}
int offset = 0;
int anchorOffset = 0;
bool hasSelection = false;
Node anchorNode = selection.anchorNode;
// TODO(ahe): Try to share walk4 methods.
void walk4(Node node) {
// TODO(ahe): Use TreeWalker when that is exposed.
// function textNodesUnder(root){
// var n, a=[], walk=document.createTreeWalker(
// root,NodeFilter.SHOW_TEXT,null,false);
// while(n=walk.nextNode()) a.push(n);
// return a;
// }
int type = node.nodeType;
if (type == Node.TEXT_NODE || type == Node.CDATA_SECTION_NODE) {
CharacterData text = node;
if (anchorNode == node) {
hasSelection = true;
anchorOffset = selection.anchorOffset + offset;
return;
}
offset += text.length;
}
var child = node.firstChild;
while (child != null) {
walk4(child);
if (hasSelection) return;
child = child.nextNode;
}
}
if (selection.isCollapsed) {
walk4(inputPre);
}
editor.currentSource = inputPre.text;
inputPre.nodes.clear();
inputPre.appendText(editor.currentSource);
if (hasSelection) {
selection.collapse(inputPre.firstChild, anchorOffset);
}
editor.isMalformedInput = false;
for (var n in new List.from(inputPre.nodes)) {
if (n is! Text) continue;
Text node = n;
String text = node.text;
Token token = tokenize(text);
int offset = 0;
editor.seenIdentifiers = new Set<String>.from(mock.identifiers);
for (; token.kind != EOF_TOKEN; token = token.next) {
Decoration decoration = editor.getDecoration(token);
if (decoration == null) continue;
bool hasSelection = false;
int selectionOffset = selection.anchorOffset;
if (selection.isCollapsed && selection.anchorNode == node) {
hasSelection = true;
selectionOffset = selection.anchorOffset;
}
int splitPoint = token.charOffset - offset;
Text str = node.splitText(splitPoint);
Text after = str.splitText(token.charCount);
offset += splitPoint + token.charCount;
inputPre.insertBefore(after, node.nextNode);
inputPre.insertBefore(decoration.applyTo(str), after);
if (hasSelection && selectionOffset > node.length) {
selectionOffset -= node.length;
if (selectionOffset > str.length) {
selectionOffset -= str.length;
selection.collapse(after, selectionOffset);
} else {
selection.collapse(str, selectionOffset);
}
}
node = after;
}
}
window.localStorage['currentSource'] = editor.currentSource;
print('Saved source');
// Discard highlighting mutations.
observer.takeRecords();
}
void onSelectionChange(Event event) {
}
void onStateChanged(InteractionState previous) {
super.onStateChanged(previous);
scheduleCompilation();
}
}
class PendingInputState extends InitialState {
PendingInputState(InteractionContext context)
: super(context);
void onInput(Event event) {
// Do nothing.
}
void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
super.onMutation(mutations, observer);
InteractionState nextState = new InitialState(context);
if (settings.enableCodeCompletion.value) {
Element parent = editor.getElementAtSelection();
Element ui;
if (parent != null) {
ui = parent.querySelector('.dart-code-completion');
if (ui != null) {
nextState = new CodeCompletionState(context, parent, ui);
}
}
}
state = nextState;
}
}
class CodeCompletionState extends InitialState {
final Element activeCompletion;
final Element ui;
int minWidth = 0;
DivElement staticResults;
SpanElement inline;
DivElement serverResults;
String inlineSuggestion;
CodeCompletionState(InteractionContext context,
this.activeCompletion,
this.ui)
: super(context);
void onInput(Event event) {
// Do nothing.
}
void onModifiedKeyUp(KeyboardEvent event) {
// TODO(ahe): Handle DOWN (jump to server results).
}
void onUnmodifiedKeyUp(KeyboardEvent event) {
switch (event.keyCode) {
case KeyCode.DOWN:
return moveDown(event);
case KeyCode.UP:
return moveUp(event);
case KeyCode.ESC:
event.preventDefault();
return endCompletion();
case KeyCode.TAB:
case KeyCode.RIGHT:
case KeyCode.ENTER:
event.preventDefault();
return endCompletion(acceptSuggestion: true);
}
}
void moveDown(Event event) {
event.preventDefault();
move(1);
}
void moveUp(Event event) {
event.preventDefault();
move(-1);
}
void move(int direction) {
Element element = editor.moveActive(direction);
if (element == null) return;
var text = activeCompletion.firstChild;
String prefix = "";
if (text is Text) prefix = text.data.trim();
updateInlineSuggestion(prefix, element.text);
}
void endCompletion({bool acceptSuggestion: false}) {
if (acceptSuggestion) {
suggestionAccepted();
}
activeCompletion.classes.remove('active');
inputPre.querySelectorAll('.hazed-suggestion').forEach((e) => e.remove());
// The above changes create mutation records. This implicitly fire mutation
// events that result in saving the source code in local storage.
// TODO(ahe): Consider making this more explicit.
state = new InitialState(context);
}
void suggestionAccepted() {
if (inlineSuggestion != null) {
Text text = new Text(inlineSuggestion);
activeCompletion.replaceWith(text);
window.getSelection().collapse(text, inlineSuggestion.length);
}
}
void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
for (MutationRecord record in mutations) {
if (!activeCompletion.contains(record.target)) {
endCompletion();
return super.onMutation(mutations, observer);
}
}
var text = activeCompletion.firstChild;
if (text is! Text) return endCompletion();
updateSuggestions(text.data.trim());
}
void onStateChanged(InteractionState previous) {
super.onStateChanged(previous);
displayCodeCompletion();
}
void displayCodeCompletion() {
Selection selection = window.getSelection();
if (selection.anchorNode is! Text) {
return endCompletion();
}
Text text = selection.anchorNode;
if (!activeCompletion.contains(text)) {
return endCompletion();
}
int anchorOffset = selection.anchorOffset;
String prefix = text.data.substring(0, anchorOffset).trim();
if (prefix.isEmpty) {
return endCompletion();
}
num height = activeCompletion.getBoundingClientRect().height;
activeCompletion.classes.add('active');
ui.nodes.clear();
inline = new SpanElement()
..classes.add('hazed-suggestion');
Text rest = text.splitText(anchorOffset);
text.parentNode.insertBefore(inline, text.nextNode);
activeCompletion.parentNode.insertBefore(
rest, activeCompletion.nextNode);
staticResults = new DivElement()
..classes.addAll(['dart-static', 'dart-limited-height']);
serverResults = new DivElement()
..style.display = 'none'
..classes.add('dart-server');
ui.nodes.addAll([staticResults, serverResults]);
ui.style.top = '${height}px';
staticResults.nodes.add(buildCompletionEntry(prefix));
updateSuggestions(prefix);
}
void updateInlineSuggestion(String prefix, String suggestion) {
inlineSuggestion = suggestion;
minWidth = max(minWidth, activeCompletion.getBoundingClientRect().width);
activeCompletion.style
..display = 'inline-block'
..minWidth = '${minWidth}px';
inline
..nodes.clear()
..appendText(suggestion.substring(prefix.length))
..style.display = '';
observer.takeRecords(); // Discard mutations.
}
void updateSuggestions(String prefix) {
if (prefix.isEmpty) {
return endCompletion();
}
Token first = tokenize(prefix);
for (Token token = first; token.kind != EOF_TOKEN; token = token.next) {
String tokenInfo = token.info.value;
if (token != first ||
tokenInfo != 'identifier' &&
tokenInfo != 'keyword') {
return endCompletion();
}
}
var borderHeight = 2; // 1 pixel border top & bottom.
num height = ui.getBoundingClientRect().height - borderHeight;
ui.style.minHeight = '${height}px';
minWidth =
max(minWidth, activeCompletion.getBoundingClientRect().width);
staticResults.nodes.clear();
serverResults.nodes.clear();
if (inlineSuggestion != null && inlineSuggestion.startsWith(prefix)) {
inline
..nodes.clear()
..appendText(inlineSuggestion.substring(prefix.length));
}
List<String> results = editor.seenIdentifiers.where(
(String identifier) {
return identifier != prefix && identifier.startsWith(prefix);
}).toList(growable: false);
results.sort();
if (results.isEmpty) results = <String>[prefix];
results.forEach((String completion) {
staticResults.nodes.add(buildCompletionEntry(completion));
});
if (settings.enableDartMind) {
// TODO(ahe): Move this code to its own function or class.
String encodedArg0 = Uri.encodeComponent('"$prefix"');
String mindQuery =
'http://dart-mind.appspot.com/rpc'
'?action=GetExportingPubCompletions'
'&arg0=$encodedArg0';
try {
var serverWatch = new Stopwatch()..start();
HttpRequest.getString(mindQuery).then((String responseText) {
serverWatch.stop();
List<String> serverSuggestions = JSON.decode(responseText);
if (!serverSuggestions.isEmpty) {
updateInlineSuggestion(prefix, serverSuggestions.first);
}
for (int i = 1; i < serverSuggestions.length; i++) {
String completion = serverSuggestions[i];
DivElement where = staticResults;
int index = results.indexOf(completion);
if (index != -1) {
List<Element> entries =
document.querySelectorAll('.dart-static>.dart-entry');
entries[index].classes.add('doubleplusgood');
} else {
if (results.length > 3) {
serverResults.style.display = 'block';
where = serverResults;
}
Element entry = buildCompletionEntry(completion);
entry.classes.add('doubleplusgood');
where.nodes.add(entry);
}
}
serverResults.appendHtml(
'<div>${serverWatch.elapsedMilliseconds}ms</div>');
// Discard mutations.
observer.takeRecords();
}).catchError((error, stack) {
window.console.dir(error);
window.console.error('$stack');
});
} catch (error, stack) {
window.console.dir(error);
window.console.error('$stack');
}
}
// Discard mutations.
observer.takeRecords();
}
Element buildCompletionEntry(String completion) {
return new DivElement()
..classes.add('dart-entry')
..appendText(completion);
}
}
Token tokenize(String text) {
var file = new StringSourceFile('', text);
return new StringScanner(file, includeComments: true).tokenize();
}
bool computeHasModifier(KeyboardEvent event) {
return
event.getModifierState("Alt") ||
event.getModifierState("AltGraph") ||
event.getModifierState("CapsLock") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("NumLock") ||
event.getModifierState("ScrollLock") ||
event.getModifierState("Scroll") ||
event.getModifierState("Win") ||
event.getModifierState("Shift") ||
event.getModifierState("SymbolLock") ||
event.getModifierState("OS");
}