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' 'self'
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk=' 'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
'sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo=' 'sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo='
'sha256-OvxDPyq6KQAoWh11DLJdBVlHHLkYYiy4EzqTjIEJbb4='
'sha256-vnm8bzrI+krtz5228JDC2DoTv0e+sfnfTCiUnO2EBAM='
'sha256-HYX1RusN7a369vYuOd1mGvxLcNL4z/MihkahAI2CH8k='
; ;
style-src 'self' https://cdnjs.cloudflare.com; style-src 'self' https://cdnjs.cloudflare.com;
img-src 'self' blob:; img-src 'self' blob:;
@ -23,6 +26,12 @@
crossorigin="anonymous" 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"> <link rel="stylesheet" href="styles/main.css">
<script src="scripts/requireConfig.js"></script> <script src="scripts/requireConfig.js"></script>
@ -35,11 +44,29 @@
></script> ></script>
<meta <meta
name="cdn-codemirror" name="cdn-cm/lib/codemirror"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js" content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js"
data-integrity="sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo=" 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> </head>
<body> <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'; 'use strict';
const DELAY_AGENTCHANGE = 500; const DELAY_AGENTCHANGE = 500;
@ -82,27 +87,68 @@ define(['codemirror'], (CodeMirror) => {
return options; return options;
} }
build(container) { buildEditor(container) {
this.codePane = makeNode('div', {'class': 'pane-code'}); const value = this.loadCode() || this.defaultCode;
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;
CodeMirror.defineMode( CodeMirror.defineMode(
'sequence', 'sequence',
() => this.parser.getCodeMirrorMode() () => this.parser.getCodeMirrorMode()
); );
this.code = new CodeMirror(this.codePane, { CodeMirror.registerHelper(
value: code, 'hint',
'sequence',
this.parser.getCodeMirrorHints()
);
const code = new CodeMirror(container, {
value,
mode: 'sequence', 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.viewPaneInner.appendChild(this.renderer.svg());
this.code.on('change', () => this.update(false)); this.code.on('change', () => this.update(false));

View File

@ -8,7 +8,11 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
let ui = null; let ui = null;
beforeEach(() => { beforeEach(() => {
parser = jasmine.createSpyObj('parser', ['parse']); parser = jasmine.createSpyObj('parser', [
'parse',
'getCodeMirrorMode',
'getCodeMirrorHints',
]);
parser.parse.and.returnValue({ parser.parse.and.returnValue({
meta: {}, meta: {},
stages: [], 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) => { define(['core/ArrayUtilities'], (array) => {
'use strict'; 'use strict';
function makeCMCommaBlock(type, exits = {}) { const CM_END = {type: '', suggest: '\n', then: {}};
return {type, then: Object.assign({ const CM_ERROR = {type: 'error', then: {'': 0}};
function makeCMCommaBlock(type, suggest, exits = {}) {
return {type, suggest, then: Object.assign({
'': 0, '': 0,
',': {type: 'operator', then: { ',': {type: 'operator', suggest: true, then: {
'': 1, '': 1,
}}, }},
}, exits)}; }, exits)};
} }
const CM_TEXT_TO_END = {type: 'string', then: {'': 0}}; const CM_TEXT_TO_END = {type: 'string', then: {'': 0, '\n': CM_END}};
const CM_IDENT_LIST_TO_END = makeCMCommaBlock('variable'); const CM_AGENT_LIST_TO_END = makeCMCommaBlock('variable', 'Agent', {
const CM_IDENT_LIST_TO_TEXT = makeCMCommaBlock('variable', { '\n': CM_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_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: { const CM_NOTE_SIDE_THEN = {
'of': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}}, 'of': {type: 'keyword', suggest: true, then: {
':': {type: 'operator', then: {'': CM_TEXT_TO_END}}, '': CM_AGENT_LIST_TO_TEXT,
'': CM_IDENT_LIST_TO_TEXT, }},
}}; ':': {type: 'operator', suggest: true, then: {
'': CM_TEXT_TO_END,
}},
'': CM_AGENT_LIST_TO_TEXT,
};
const CM_CONNECT = {type: 'keyword', then: { const CM_NOTE_LSIDE = {
'': CM_IDENT_LIST_TO_TEXT, 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: { const CM_COMMANDS = {type: 'error', then: {
'title': {type: 'keyword', then: {'': CM_TEXT_TO_END}}, 'title': {type: 'keyword', suggest: true, then: {
'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: {
'': CM_TEXT_TO_END, '': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
}}, }},
'else': {type: 'keyword', then: { 'terminators': {type: 'keyword', suggest: true, then: {
'if': {type: 'keyword', 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, '': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}}, ':': {type: 'operator', suggest: true, then: {
}},
}},
'repeat': {type: 'keyword', then: {
'': CM_TEXT_TO_END, '': CM_TEXT_TO_END,
':': {type: 'operator', then: {'': CM_TEXT_TO_END}},
}}, }},
'note': {type: 'keyword', then: { '\n': CM_END,
'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}},
}}, }},
'state': {type: 'keyword', then: { 'else': {type: 'keyword', suggest: ['else\n', 'else if: '], then: {
'over': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}}, '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: { '\n': CM_END,
':': {type: 'operator', then: {}}, }},
'with': {type: 'keyword', then: { 'repeat': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', 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, '': 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,
'<--': CM_CONNECT, '<--': CM_CONNECT,
'<->': CM_CONNECT, '<->': CM_CONNECT,
'<-->': CM_CONNECT, '<-->': CM_CONNECT,
':': {type: 'operator', then: {}}, ':': {type: 'operator', suggest: true, override: 'Label', then: {}},
'': 0, '': 0,
}}, }},
}}; }};
function cmCheckToken(state, partial) { function cmGetSuggestions(state, token, {suggest, then}) {
if(!partial && state.current === '\n') { if(token === '') {
// quoted newline is interpreted as a command separator; return state['known' + suggest];
// probably not what the writer expected, so highlight it } else if(suggest === true) {
state.line.length = 0; if(Object.keys(then).length > 0) {
return 'warning'; 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; let current = CM_COMMANDS;
const path = [current]; const path = [current];
for(let i = 0; i < state.line.length; ++ i) {
const token = state.line[i]; state.line.forEach((token, i) => {
let found = current.then[token] || current.then['']; if(i === state.line.length - 1) {
if(found === undefined) { state.completions = cmMakeCompletions(state, path);
return 'error';
} }
const found = current.then[token] || current.then[''];
if(typeof found === 'number') { if(typeof found === 'number') {
path.length -= found; path.length -= found;
current = array.last(path);
} else { } else {
path.push(found); path.push(found || CM_ERROR);
current = found;
} }
current = array.last(path);
updateSuggestion(state, suggestions, token, current);
});
if(eol) {
updateSuggestion(state, suggestions, '', {});
} }
state.nextCompletions = cmMakeCompletions(state, path);
return current.type; return current.type;
} }
@ -122,6 +223,11 @@ define(['core/ArrayUtilities'], (array) => {
return { return {
currentType: -1, currentType: -1,
current: '', current: '',
knownAgent: [],
knownLabel: [],
beginCompletions: cmMakeCompletions({}, [CM_COMMANDS]),
completions: [],
nextCompletions: [],
line: [], line: [],
indent: 0, indent: 0,
}; };
@ -165,7 +271,13 @@ define(['core/ArrayUtilities'], (array) => {
return 'comment'; return 'comment';
} }
state.line.push(state.current); 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) { _tokenEOLFound(stream, state, block) {
@ -174,7 +286,7 @@ define(['core/ArrayUtilities'], (array) => {
return 'comment'; return 'comment';
} }
state.line.push(state.current); state.line.push(state.current);
const type = cmCheckToken(state, true); const type = cmCheckToken(state, false);
state.line.pop(); state.line.pop();
return type; return type;
} }
@ -194,6 +306,7 @@ define(['core/ArrayUtilities'], (array) => {
} }
token(stream, state) { token(stream, state) {
state.completions = state.nextCompletions;
if(stream.sol() && state.currentType === -1) { if(stream.sol() && state.currentType === -1) {
state.line.length = 0; state.line.length = 0;
} }

View File

@ -247,7 +247,7 @@ define(['core/ArrayUtilities'], (array) => {
meta: { meta: {
title: meta.title, title: meta.title,
}, },
agents: this.agents, agents: this.agents.slice(),
stages: globals.stages, 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'; 'use strict';
function execAt(str, reg, i) { function execAt(str, reg, i) {
@ -355,6 +363,10 @@ define(['core/ArrayUtilities', './CodeMirrorMode'], (array, CMMode) => {
return new CMMode(TOKENS); return new CMMode(TOKENS);
} }
getCodeMirrorHints() {
return CMHints.getHints;
}
splitLines(tokens) { splitLines(tokens) {
const lines = []; const lines = [];
let line = []; 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.defineMode = () => null;
CodeMirror.registerHelper = () => null;
return CodeMirror; return CodeMirror;
}); });

View File

@ -24,6 +24,17 @@ html, body {
.cm-s-default .cm-string {color: #221111;} .cm-s-default .cm-string {color: #221111;}
.cm-s-default .cm-error {color: #FF0000;} .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 { .pane-view {
position: absolute; position: absolute;
left: 30%; left: 30%;

View File

@ -57,7 +57,10 @@
data-integrity="sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8=" 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 --> <!-- test files defined in scripts/specs.js -->