Skip to content

Commit

Permalink
Initial Implementation of Autocomplete (#77)
Browse files Browse the repository at this point in the history
* Added autocomplete box that will display options

* hide and display the autocomplete box

* add the matching strings to the autocomplete box

* fixed bug when adding new lines

* added matching prefixes being bolded

* _wordMatches now compares all documented forms

* fixed the width and made the height of the autocomplete box variable

fixed the formatting in the autobox

* fixed border around words in autobox

* Make the autobox invisible when displaying docs

added some comments

* docs stay up after one space to handle cases like cons vs cons-stream

The autocomplete box does show up unless there exists a ( in front of the word

* now looks at the last symbol next to an open parens to decide what doc to show

* fixed autobox disappearing on empty parens

* rewrote and cleaned up the _currOp function

* finds both the last op and the second to last op

* changed the logic for determining if an operation represents a string being currently typed vs one that is already complete

* wrapped the autobox with a render element

* removed the wrapper autBox instead of just the child nodes

* allow autocomplete to be toggled on and off

* autocomplete toggle remembered in different sessions

* will complete word on tab if the docs for that word are displayed

* fixed spelling error with local storage of autocomplete preference

* scroll the autobox into view if it expands below the current window

* made some syntax/naming fixes

* cleared up stuff with local storage

* cleaned up comment formatting and removed unnecesary code

* Used Tuple2 instead of a generic List

* changed some variable names

* fixed some comments and fixed bug when there exists a space after a open parentheses

* fixed some variable names and cleared up some comments

* added dependency and fixed an error with a space before the name of an operation
  • Loading branch information
dnachum committed Oct 22, 2018
1 parent fc2493a commit 95f6b11
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 10 deletions.
148 changes: 141 additions & 7 deletions lib/src/web_ui/code_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ library web_ui.code_input;
import 'dart:async';
import 'dart:html';

import 'package:tuple/tuple.dart';

import 'package:cs61a_scheme/cs61a_scheme.dart';
import 'package:cs61a_scheme/cs61a_scheme_web.dart';

import 'highlight.dart';

Expand All @@ -14,22 +17,35 @@ const List<SchemeSymbol> noIndentForms = [
SchemeSymbol("define-macro")
];

bool _isAutocompleteEnabled = false;

class CodeInput {
Element element;
Element _autoBox;
Element _autoBoxWrapper;
Map<String, Docs> wordToDocs;
bool _active = true;
final List<StreamSubscription> _subs = [];
String tabComplete = "";

Function(String code) runCode;
Function(int parens) parenListener;

CodeInput(Element log, this.runCode, {this.parenListener}) {
CodeInput(Element log, this.runCode, this.wordToDocs, {this.parenListener}) {
element = SpanElement()
..classes = ['code-input']
..contentEditable = 'true';
_autoBox = DivElement()
..classes = ["docs"]
..style.visibility = "hidden";
_autoBoxWrapper = DivElement()
..classes = ["render"]
..append(_autoBox);
_subs.add(element.onKeyPress.listen(_onInputKeyPress));
_subs.add(element.onKeyDown.listen(_keyListener));
_subs.add(element.onKeyUp.listen(_keyListener));
log.append(element);
log.append(_autoBoxWrapper);
element.focus();
parenListener ??= (_) => null;
parenListener(missingParens);
Expand All @@ -49,11 +65,15 @@ class CodeInput {
void deactivate() {
_active = false;
element.contentEditable = 'false';
_autoBoxWrapper.remove();
for (var sub in _subs) {
sub.cancel();
}
}

void enableAutocomplete() => _isAutocompleteEnabled = true;
void disableAutocomplete() => _isAutocompleteEnabled = false;

Future highlight(
{bool saveCursor = false, int cursor, bool atEnd = false}) async {
if (saveCursor) {
Expand Down Expand Up @@ -101,17 +121,59 @@ class CodeInput {
parenListener(missingParens);
}

/// Determines how much space to indent the next line, based on parens
/// Determines the operation at the last open parens.
///
/// Returns a two item list where the first item indicates the word that was matched
/// and the second item indicates if that word is the full string (true)
/// or is in progress of being written out (false).
Tuple2<String, bool> _currOp(String inputText, int position,
[int fromLast = 1]) {
List<String> splitLines = inputText.substring(0, position).split("\n");
bool isMultipleLines = false;
String refLine;
int totalMissingCount = 0;

for (refLine in splitLines.reversed) {
totalMissingCount += countParens(refLine) ?? 0;
// Find the first line with an open paren but no close parentheses.
if (totalMissingCount >= fromLast) break;
isMultipleLines = true;
}
// If there are not enough open parentheses, return the default output value.
if (totalMissingCount >= fromLast) {
int strIndex = refLine.indexOf("(");
while (strIndex != -1 && strIndex < (refLine.length - 1)) {
int nextClose = refLine.indexOf(")", strIndex + 1);
int nextOpen = refLine.indexOf("(", strIndex + 1);
// Find the open parentheses that corresponds to the missing closed parentheses.
if (totalMissingCount > fromLast) {
totalMissingCount -= 1;
} else if (nextOpen == -1 || nextClose == -1 || nextOpen < nextClose) {
// Find the operations separated by any number of spaces, open parentheses, or closed parentheses.
List splitRef =
refLine.substring(strIndex + 1).split(RegExp("[ ()]+"));
String op =
splitRef.firstWhere((s) => s.isNotEmpty, orElse: () => "");
return Tuple2(op,
(splitRef.length - splitRef.indexOf(op)) > 1 || isMultipleLines);
}
strIndex = nextOpen;
}
}
return const Tuple2("", true);
}

/// Determines how much space to indent the next line, based on parens.
int _countSpace(String inputText, int position) {
List<String> splitLines = inputText.substring(0, position).split("\n");
// If the cursor is at the end of the line but not the end of the input, we
// must find that line and start counting parens from there
// must find that line and start counting parens from there.
String refLine;
int totalMissingCount = 0;
for (refLine in splitLines.reversed) {
// Truncate to position of cursor when in middle of the line
// Truncate to position of cursor when in middle of the line.
totalMissingCount += countParens(refLine);
// Find the first line with an open paren but no close paren
// Find the first line with an open paren but no close paren.
if (totalMissingCount >= 1) break;
}
if (totalMissingCount == 0) {
Expand All @@ -121,13 +183,13 @@ class CodeInput {
while (strIndex < (refLine.length - 1)) {
int nextClose = refLine.indexOf(")", strIndex + 1);
int nextOpen = refLine.indexOf("(", strIndex + 1);
// Find the open paren that corresponds to the missing closed paren
// Find the open paren that corresponds to the missing closed paren.
if (totalMissingCount > 1) {
totalMissingCount -= 1;
} else if (nextOpen == -1 || nextOpen < nextClose) {
Iterable<Expression> tokens = tokenizeLine(refLine.substring(strIndex));
Expression symbol = tokens.elementAt(1);
// Align subexpressions if any exist; otherwise, indent by two spaces
// Align subexpressions if any exist; otherwise, indent by two spaces.
if (symbol == const SchemeSymbol("(")) {
return strIndex + 1;
} else if (noIndentForms.contains(symbol)) {
Expand All @@ -145,8 +207,75 @@ class CodeInput {
return strIndex + 2;
}

/// Find the list of words that contain the same prefix as currWord.
List<String> _wordMatches(String currWord, bool fullWord) {
List<String> matchingWords = [];
for (String schemeWord in wordToDocs.keys) {
if (((schemeWord.length > currWord.length) && !fullWord) ||
schemeWord.length == currWord.length) {
if (schemeWord.substring(0, currWord.length) == currWord) {
matchingWords.add(schemeWord);
}
}
}
return matchingWords;
}

/// Finds and displays the possible words that the user may be typing.
void _autocomplete() {
// Find the text to the left of where the typing cursor currently is.
int cursorPos = findPosition(element, window.getSelection().getRangeAt(0));
List<String> matchingWords = [];
int currLength = 0;
// Find the last word that being typed [output] or the second to last operation that was typed [output2].
// Used for the case where an empty open parentheses is being typed.
// Eventually, may be used to implement different autocomplete behavior for special forms.
Tuple2<String, bool> output = _currOp(element.text, cursorPos);
Tuple2<String, bool> secondToLastOutput =
_currOp(element.text, cursorPos, 2);
String match = output.item1;
bool isFullWord = output.item2;
if (match.isEmpty) {
match = secondToLastOutput.item1;
isFullWord = secondToLastOutput.item2;
}
if (match.isNotEmpty) {
matchingWords = _wordMatches(match, isFullWord);
currLength = match.length;
}
// Clear whatever is currently in the box.
_autoBox.children = [];
_autoBox.classes = ["docs"];
if (matchingWords.isEmpty) {
// If there are no matching words, hide the autocomplete box.
tabComplete = "";
_autoBox.style.visibility = "hidden";
} else if (matchingWords.length == 1) {
// If there is only one matching word, display the docs for that word.
render(wordToDocs[matchingWords.first], _autoBox);
_autoBox.style.visibility = "hidden";
_autoBox.children.last.style.visibility = "visible";
tabComplete = matchingWords.first.substring(currLength);
} else {
tabComplete = "";
// Add each matching word as its own element for formatting purposes.
for (String match in matchingWords) {
_autoBox.append(SpanElement()
..classes = ["autobox-word"]
// Bold the matching characters.
..innerHtml =
"<strong>${match.substring(0, currLength)}</strong>${match.substring(currLength)}");
}
_autoBox.style.visibility = "visible";
}
_autoBox.scrollIntoView();
}

_keyListener(KeyboardEvent event) async {
int key = event.keyCode;
if (_isAutocompleteEnabled) {
_autocomplete();
}
if (key == KeyCode.BACKSPACE) {
await delay(0);
parenListener(missingParens);
Expand All @@ -155,6 +284,11 @@ class CodeInput {
await delay(0);
parenListener(missingParens);
await highlight(saveCursor: true);
} else if (key == KeyCode.TAB) {
event.preventDefault();
int cursor = findPosition(element, window.getSelection().getRangeAt(0));
element.appendText(tabComplete);
await highlight(cursor: cursor + tabComplete.length + 1);
}
}
}
25 changes: 22 additions & 3 deletions lib/src/web_ui/repl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ class Repl {
int historyIndex = -1;

final String prompt;

Repl(this.interpreter, Element parent, {this.prompt = 'scm> '}) {
if (window.localStorage.containsKey('#repl-history')) {
var decoded = json.decode(window.localStorage['#repl-history']);
Expand All @@ -38,6 +37,12 @@ class Repl {
status = SpanElement()..classes = ['repl-status'];
container.append(status);
buildNewInput();
if (window.localStorage.containsKey('#autocomplete')) {
String val = window.localStorage['#autocomplete'];
if (val == 'enabled') {
activeInput.enableAutocomplete();
}
}
interpreter.logger = (logging, [newline = true]) {
var box = SpanElement();
activeLoggingArea.append(box);
Expand Down Expand Up @@ -79,6 +84,19 @@ class Repl {
autodraw = false;
return undefined;
}, 0);
addBuiltin(env, const SchemeSymbol('autocomplete'), (_a, _b) {
logText('While typing, will display a list of possible procedures.\n'
'(disable-autocomplete) to disable\n');
activeInput.enableAutocomplete();
window.localStorage['#autocomplete'] = 'enabled';
return undefined;
}, 0);
addBuiltin(env, const SchemeSymbol('disable-autocomplete'), (_a, _b) {
logText('Autocomplete disabled\n');
activeInput.disableAutocomplete();
window.localStorage['#autocomplete'] = 'disabled';
return undefined;
}, 0);
}

buildNewInput() {
Expand All @@ -90,8 +108,9 @@ class Repl {
..text = prompt
..classes = ['repl-prompt'];
container.append(activePrompt);
activeInput =
CodeInput(container, runCode, parenListener: updateParenStatus);
activeInput = CodeInput(
container, runCode, allDocumentedForms(interpreter.globalEnv),
parenListener: updateParenStatus);
container.scrollTop = container.scrollHeight;
}

Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies:
analyzer: ^0.32.4
markdown: ^2.0.2
quiver: ^2.0.0+1
tuple: ^1.0.2

dev_dependencies:
# Implementation library
Expand Down
5 changes: 5 additions & 0 deletions web/assets/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ body {
}
}

.autobox-word {
margin: 10px;
display: inline-block;
}

.code-input {
display: inline-block;
}
Expand Down
2 changes: 2 additions & 0 deletions web/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const String motd = "**61A Scheme Web Interpreter 2.0.0-beta**"
**Other Useful Commands**
`(docs symbol)` to display documentation for some built-in or special form
`(autocomplete)` to display scheme built-ins and documentation while typing [Try It](:try-autocomplete)
`(clear)` to clear all output on the screen
`(theme 'id)` to change the interpreter's theme
[default](:try-default) [solarized](:try-solarized) """
Expand Down Expand Up @@ -81,6 +82,7 @@ startScheme(WebLibrary webLibrary) async {
addDemo(demos, 'try-draw', "(draw '(1 2 3))");
addDemo(demos, 'try-chess', "(import 'scm/apps/chess)");
addDemo(demos, 'try-ad', "(autodraw)");
addDemo(demos, 'try-autocomplete', "(autocomplete)");
addDemo(demos, 'try-default', "(theme 'default)");
addDemo(demos, 'try-solarized', "(theme 'solarized)");
addDemo(demos, 'try-monochrome', "(theme 'monochrome)");
Expand Down

0 comments on commit 95f6b11

Please sign in to comment.