Add autocomplete to editor [#4]

This commit is contained in:
David Evans 2017-10-29 16:41:33 +00:00
parent 4d301adf31
commit 55b5232fa6
13 changed files with 377 additions and 88 deletions

View File

@ -8,6 +8,9 @@
'self'
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
'sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo='
'sha256-OvxDPyq6KQAoWh11DLJdBVlHHLkYYiy4EzqTjIEJbb4='
'sha256-vnm8bzrI+krtz5228JDC2DoTv0e+sfnfTCiUnO2EBAM='
'sha256-HYX1RusN7a369vYuOd1mGvxLcNL4z/MihkahAI2CH8k='
;
style-src 'self' https://cdnjs.cloudflare.com;
img-src 'self' blob:;
@ -23,6 +26,12 @@
crossorigin="anonymous"
>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/addon/hint/show-hint.min.css"
integrity="sha256-Ng5EdzHS/CC37tR7tE75e4Th9+fBvOB4eYITOkXS22Q="
crossorigin="anonymous"
>
<link rel="stylesheet" href="styles/main.css">
<script src="scripts/requireConfig.js"></script>
@ -35,11 +44,29 @@
></script>
<meta
name="cdn-codemirror"
name="cdn-cm/lib/codemirror"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js"
data-integrity="sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo="
>
<meta
name="cdn-cm/addon/hint/show-hint"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/addon/hint/show-hint.min.js"
data-integrity="sha256-OvxDPyq6KQAoWh11DLJdBVlHHLkYYiy4EzqTjIEJbb4="
>
<meta
name="cdn-cm/addon/edit/trailingspace"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/addon/edit/trailingspace.min.js"
data-integrity="sha256-vnm8bzrI+krtz5228JDC2DoTv0e+sfnfTCiUnO2EBAM="
>
<meta
name="cdn-cm/addon/comment/comment"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/addon/comment/comment.min.js"
data-integrity="sha256-HYX1RusN7a369vYuOd1mGvxLcNL4z/MihkahAI2CH8k="
>
</head>
<body>

View File

@ -1,4 +1,9 @@
define(['codemirror'], (CodeMirror) => {
define([
'cm/lib/codemirror',
'cm/addon/hint/show-hint',
'cm/addon/edit/trailingspace',
'cm/addon/comment/comment',
], (CodeMirror) => {
'use strict';
const DELAY_AGENTCHANGE = 500;
@ -82,27 +87,68 @@ define(['codemirror'], (CodeMirror) => {
return options;
}
build(container) {
this.codePane = makeNode('div', {'class': 'pane-code'});
this.viewPane = makeNode('div', {'class': 'pane-view'});
this.viewPaneInner = makeNode('div', {'class': 'pane-view-inner'});
const options = this.buildOptions();
this.viewPane.appendChild(this.viewPaneInner);
this.viewPane.appendChild(options);
container.appendChild(this.codePane);
container.appendChild(this.viewPane);
const code = this.loadCode() || this.defaultCode;
buildEditor(container) {
const value = this.loadCode() || this.defaultCode;
CodeMirror.defineMode(
'sequence',
() => this.parser.getCodeMirrorMode()
);
this.code = new CodeMirror(this.codePane, {
value: code,
CodeMirror.registerHelper(
'hint',
'sequence',
this.parser.getCodeMirrorHints()
);
const code = new CodeMirror(container, {
value,
mode: 'sequence',
lineNumbers: true,
showTrailingSpace: true,
extraKeys: {
'Tab': (cm) => cm.execCommand('indentMore'),
'Shift-Tab': (cm) => cm.execCommand('indentLess'),
'Cmd-/': (cm) => cm.toggleComment({padding: ''}),
'Ctrl-/': (cm) => cm.toggleComment({padding: ''}),
'Ctrl-Space': 'autocomplete',
'Ctrl-Enter': 'autocomplete',
'Cmd-Enter': 'autocomplete',
},
});
let lastKey = 0;
code.on('keydown', (cm, event) => {
lastKey = event.keyCode;
});
code.on('endCompletion', () => {
lastKey = 0;
});
code.on('change', (cm) => {
if(cm.state.completionActive) {
return;
}
if(lastKey === 13 || lastKey === 8) {
return;
}
lastKey = 0;
CodeMirror.commands.autocomplete(cm, null, {
completeSingle: false,
});
});
return code;
}
build(container) {
const codePane = makeNode('div', {'class': 'pane-code'});
const viewPane = makeNode('div', {'class': 'pane-view'});
this.viewPaneInner = makeNode('div', {'class': 'pane-view-inner'});
const options = this.buildOptions();
viewPane.appendChild(this.viewPaneInner);
viewPane.appendChild(options);
container.appendChild(codePane);
container.appendChild(viewPane);
this.code = this.buildEditor(codePane);
this.viewPaneInner.appendChild(this.renderer.svg());
this.code.on('change', () => this.update(false));

View File

@ -8,7 +8,11 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
let ui = null;
beforeEach(() => {
parser = jasmine.createSpyObj('parser', ['parse']);
parser = jasmine.createSpyObj('parser', [
'parse',
'getCodeMirrorMode',
'getCodeMirrorHints',
]);
parser.parse.and.returnValue({
meta: {},
stages: [],

View File

@ -0,0 +1,57 @@
define(() => {
'use strict';
const TRIMMER = /^([ \t]*)(.*)$/;
const SQUASH_START = /^[ \t\r\n:,]/;
function getHints(cm) {
const cur = cm.getCursor();
const token = cm.getTokenAt(cur);
let partial = token.string;
if(token.end > cur.ch) {
partial = partial.substr(0, cur.ch - token.start);
}
const parts = TRIMMER.exec(partial);
partial = parts[2];
const from = token.start + parts[1].length;
const continuation = (cur.ch > 0 && token.state.line.length > 0);
let comp = (continuation ?
token.state.completions :
token.state.beginCompletions
);
if(!continuation) {
comp = comp.concat(token.state.knownAgent);
}
const ln = cm.getLine(cur.line);
const wordFrom = {line: cur.line, ch: from};
const squashFrom = {line: cur.line, ch: from};
if(from > 0 && ln[from - 1] === ' ') {
squashFrom.ch --;
}
const wordTo = {line: cur.line, ch: token.end};
const list = (comp
.filter((opt) => opt.startsWith(partial))
.map((opt) => {
return {
text: opt,
displayText: (opt === '\n') ? '<END>' : opt.trim(),
className: (opt === '\n') ? 'pick-virtual' : null,
from: SQUASH_START.test(opt) ? squashFrom : wordFrom,
to: wordTo,
};
})
);
return {
list,
from: wordFrom,
to: wordTo,
};
}
return {
getHints,
};
});

View File

@ -1,114 +1,215 @@
define(['core/ArrayUtilities'], (array) => {
'use strict';
function makeCMCommaBlock(type, exits = {}) {
return {type, then: Object.assign({
const CM_END = {type: '', suggest: '\n', then: {}};
const CM_ERROR = {type: 'error', then: {'': 0}};
function makeCMCommaBlock(type, suggest, exits = {}) {
return {type, suggest, then: Object.assign({
'': 0,
',': {type: 'operator', then: {
',': {type: 'operator', suggest: true, then: {
'': 1,
}},
}, exits)};
}
const CM_TEXT_TO_END = {type: 'string', then: {'': 0}};
const CM_IDENT_LIST_TO_END = makeCMCommaBlock('variable');
const CM_IDENT_LIST_TO_TEXT = makeCMCommaBlock('variable', {
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
const CM_TEXT_TO_END = {type: 'string', then: {'': 0, '\n': CM_END}};
const CM_AGENT_LIST_TO_END = makeCMCommaBlock('variable', 'Agent', {
'\n': CM_END,
});
const CM_AGENT_LIST_TO_TEXT = makeCMCommaBlock('variable', 'Agent', {
':': {type: 'operator', suggest: true, then: {'': CM_TEXT_TO_END}},
});
const CM_AGENT_LIST_TO_OPTTEXT = makeCMCommaBlock('variable', 'Agent', {
':': {type: 'operator', suggest: true, then: {'': CM_TEXT_TO_END}},
'\n': CM_END,
});
const CM_NOTE_SIDE = {type: 'keyword', then: {
'of': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}},
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
'': CM_IDENT_LIST_TO_TEXT,
}};
const CM_NOTE_SIDE_THEN = {
'of': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_TEXT,
}},
':': {type: 'operator', suggest: true, then: {
'': CM_TEXT_TO_END,
}},
'': CM_AGENT_LIST_TO_TEXT,
};
const CM_CONNECT = {type: 'keyword', then: {
'': CM_IDENT_LIST_TO_TEXT,
const CM_NOTE_LSIDE = {
type: 'keyword',
suggest: ['left of ', 'left: '],
then: CM_NOTE_SIDE_THEN,
};
const CM_NOTE_RSIDE = {
type: 'keyword',
suggest: ['right of ', 'right: '],
then: CM_NOTE_SIDE_THEN,
};
const CM_CONNECT = {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_OPTTEXT,
}};
const CM_COMMANDS = {type: 'error', then: {
'title': {type: 'keyword', then: {'': CM_TEXT_TO_END}},
'terminators': {type: 'keyword', then: {
'none': {type: 'keyword', then: {}},
'cross': {type: 'keyword', then: {}},
'box': {type: 'keyword', then: {}},
'bar': {type: 'keyword', then: {}},
}},
'define': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_END}},
'begin': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_END}},
'end': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_END}},
'if': {type: 'keyword', then: {
'title': {type: 'keyword', suggest: true, then: {
'': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
}},
'else': {type: 'keyword', then: {
'if': {type: 'keyword', then: {
'terminators': {type: 'keyword', suggest: true, then: {
'none': {type: 'keyword', suggest: true, then: {}},
'cross': {type: 'keyword', suggest: true, then: {}},
'box': {type: 'keyword', suggest: true, then: {}},
'bar': {type: 'keyword', suggest: true, then: {}},
}},
'define': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_END,
}},
'begin': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_END,
}},
'end': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_END,
'\n': CM_END,
}},
'if': {type: 'keyword', suggest: true, then: {
'': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
}},
}},
'repeat': {type: 'keyword', then: {
':': {type: 'operator', suggest: true, then: {
'': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
}},
'note': {type: 'keyword', then: {
'over': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}},
'left': CM_NOTE_SIDE,
'right': CM_NOTE_SIDE,
'between': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}},
'\n': CM_END,
}},
'state': {type: 'keyword', then: {
'over': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}},
'else': {type: 'keyword', suggest: ['else\n', 'else if: '], then: {
'if': {type: 'keyword', suggest: 'if: ', then: {
'': CM_TEXT_TO_END,
':': {type: 'operator', suggest: true, then: {
'': CM_TEXT_TO_END,
}},
'text': {type: 'keyword', then: {
'left': CM_NOTE_SIDE,
'right': CM_NOTE_SIDE,
}},
'simultaneously': {type: 'keyword', then: {
':': {type: 'operator', then: {}},
'with': {type: 'keyword', then: {
'': {type: 'variable', then: {
'\n': CM_END,
}},
'repeat': {type: 'keyword', suggest: true, then: {
'': CM_TEXT_TO_END,
':': {type: 'operator', suggest: true, then: {
'': CM_TEXT_TO_END,
}},
'\n': CM_END,
}},
'note': {type: 'keyword', suggest: true, then: {
'over': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_TEXT,
}},
'left': CM_NOTE_LSIDE,
'right': CM_NOTE_RSIDE,
'between': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_TEXT,
}},
}},
'state': {type: 'keyword', suggest: 'state over ', then: {
'over': {type: 'keyword', suggest: true, then: {
'': CM_AGENT_LIST_TO_TEXT,
}},
}},
'text': {type: 'keyword', suggest: true, then: {
'left': CM_NOTE_LSIDE,
'right': CM_NOTE_RSIDE,
}},
'simultaneously': {type: 'keyword', suggest: true, then: {
':': {type: 'operator', suggest: true, then: {}},
'with': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', suggest: 'Label', then: {
'': 0,
':': {type: 'operator', then: {}},
':': {type: 'operator', suggest: true, then: {}},
}},
}},
}},
'': {type: 'variable', then: {
'': {type: 'variable', suggest: 'Agent', then: {
'->': CM_CONNECT,
'-->': CM_CONNECT,
'<-': CM_CONNECT,
'<--': CM_CONNECT,
'<->': CM_CONNECT,
'<-->': CM_CONNECT,
':': {type: 'operator', then: {}},
':': {type: 'operator', suggest: true, override: 'Label', then: {}},
'': 0,
}},
}};
function cmCheckToken(state, partial) {
if(!partial && state.current === '\n') {
// quoted newline is interpreted as a command separator;
// probably not what the writer expected, so highlight it
state.line.length = 0;
return 'warning';
function cmGetSuggestions(state, token, {suggest, then}) {
if(token === '') {
return state['known' + suggest];
} else if(suggest === true) {
if(Object.keys(then).length > 0) {
return [token + ' '];
} else {
return [token + '\n'];
}
} else if(Array.isArray(suggest)) {
return suggest;
} else if(suggest) {
return [suggest];
} else {
return null;
}
}
function cmMakeCompletions(state, path) {
const comp = [];
const {then} = array.last(path);
Object.keys(then).forEach((token) => {
let next = then[token];
if(typeof next === 'number') {
next = path[path.length - next - 1];
}
array.mergeSets(comp, cmGetSuggestions(state, token, next));
});
return comp;
}
function updateSuggestion(state, locals, token, {suggest, override}) {
if(locals.type) {
if(suggest !== locals.type) {
if(override) {
locals.type = override;
}
array.mergeSets(
state['known' + locals.type],
[locals.value]
);
locals.type = '';
} else {
locals.value += token + ' ';
}
} else if(typeof suggest === 'string' && state['known' + suggest]) {
locals.type = suggest;
locals.value = token + ' ';
}
}
function cmCheckToken(state, eol) {
const suggestions = {
type: '',
value: '',
};
let current = CM_COMMANDS;
const path = [current];
for(let i = 0; i < state.line.length; ++ i) {
const token = state.line[i];
let found = current.then[token] || current.then[''];
if(found === undefined) {
return 'error';
state.line.forEach((token, i) => {
if(i === state.line.length - 1) {
state.completions = cmMakeCompletions(state, path);
}
const found = current.then[token] || current.then[''];
if(typeof found === 'number') {
path.length -= found;
current = array.last(path);
} else {
path.push(found);
current = found;
path.push(found || CM_ERROR);
}
current = array.last(path);
updateSuggestion(state, suggestions, token, current);
});
if(eol) {
updateSuggestion(state, suggestions, '', {});
}
state.nextCompletions = cmMakeCompletions(state, path);
return current.type;
}
@ -122,6 +223,11 @@ define(['core/ArrayUtilities'], (array) => {
return {
currentType: -1,
current: '',
knownAgent: [],
knownLabel: [],
beginCompletions: cmMakeCompletions({}, [CM_COMMANDS]),
completions: [],
nextCompletions: [],
line: [],
indent: 0,
};
@ -165,7 +271,13 @@ define(['core/ArrayUtilities'], (array) => {
return 'comment';
}
state.line.push(state.current);
return cmCheckToken(state, false);
if(state.current === '\n') {
// quoted newline is interpreted as a command separator;
// probably not what the writer expected, so highlight it
state.line.length = 0;
return 'warning';
}
return cmCheckToken(state, stream.eol());
}
_tokenEOLFound(stream, state, block) {
@ -174,7 +286,7 @@ define(['core/ArrayUtilities'], (array) => {
return 'comment';
}
state.line.push(state.current);
const type = cmCheckToken(state, true);
const type = cmCheckToken(state, false);
state.line.pop();
return type;
}
@ -194,6 +306,7 @@ define(['core/ArrayUtilities'], (array) => {
}
token(stream, state) {
state.completions = state.nextCompletions;
if(stream.sol() && state.currentType === -1) {
state.line.length = 0;
}

View File

@ -247,7 +247,7 @@ define(['core/ArrayUtilities'], (array) => {
meta: {
title: meta.title,
},
agents: this.agents,
agents: this.agents.slice(),
stages: globals.stages,
};
}

View File

@ -1,4 +1,12 @@
define(['core/ArrayUtilities', './CodeMirrorMode'], (array, CMMode) => {
define([
'core/ArrayUtilities',
'./CodeMirrorMode',
'./CodeMirrorHints',
], (
array,
CMMode,
CMHints
) => {
'use strict';
function execAt(str, reg, i) {
@ -355,6 +363,10 @@ define(['core/ArrayUtilities', './CodeMirrorMode'], (array, CMMode) => {
return new CMMode(TOKENS);
}
getCodeMirrorHints() {
return CMHints.getHints;
}
splitLines(tokens) {
const lines = [];
let line = [];

View File

@ -0,0 +1,5 @@
define([], () => {
'use strict';
return null;
});

View File

@ -0,0 +1,5 @@
define([], () => {
'use strict';
return null;
});

View File

@ -0,0 +1,5 @@
define([], () => {
'use strict';
return null;
});

View File

@ -13,6 +13,7 @@ define([], () => {
}
CodeMirror.defineMode = () => null;
CodeMirror.registerHelper = () => null;
return CodeMirror;
});

View File

@ -24,6 +24,17 @@ html, body {
.cm-s-default .cm-string {color: #221111;}
.cm-s-default .cm-error {color: #FF0000;}
.cm-s-default .cm-warning {
background: #FFFF00;
}
.cm-s-default .cm-trailingspace {
background: rgba(255, 0, 0, 0.5);
}
.pick-virtual {
color: #777777;
}
.pane-view {
position: absolute;
left: 30%;

View File

@ -57,7 +57,10 @@
data-integrity="sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8="
>
<meta name="cdn-codemirror" content="stubs/codemirror">
<meta name="cdn-cm/lib/codemirror" content="stubs/codemirror">
<meta name="cdn-cm/addon/hint/show-hint" content="stubs/codemirror-show-hint">
<meta name="cdn-cm/addon/edit/trailingspace" content="stubs/codemirror-trailingspace">
<meta name="cdn-cm/addon/comment/comment" content="stubs/codemirror-comment">
<!-- test files defined in scripts/specs.js -->