From 55b5232fa6e6c61301e26d7b2f58c1c0da570b28 Mon Sep 17 00:00:00 2001 From: David Evans Date: Sun, 29 Oct 2017 16:41:33 +0000 Subject: [PATCH] Add autocomplete to editor [#4] --- index.html | 29 ++- scripts/interface/Interface.js | 78 +++++-- scripts/interface/Interface_spec.js | 6 +- scripts/sequence/CodeMirrorHints.js | 57 +++++ scripts/sequence/CodeMirrorMode.js | 247 ++++++++++++++++------ scripts/sequence/Generator.js | 2 +- scripts/sequence/Parser.js | 14 +- scripts/stubs/codemirror-comment.js | 5 + scripts/stubs/codemirror-show-hint.js | 5 + scripts/stubs/codemirror-trailingspace.js | 5 + scripts/stubs/codemirror.js | 1 + styles/main.css | 11 + test.htm | 5 +- 13 files changed, 377 insertions(+), 88 deletions(-) create mode 100644 scripts/sequence/CodeMirrorHints.js create mode 100644 scripts/stubs/codemirror-comment.js create mode 100644 scripts/stubs/codemirror-show-hint.js create mode 100644 scripts/stubs/codemirror-trailingspace.js diff --git a/index.html b/index.html index 71e79ed..f8754d0 100644 --- a/index.html +++ b/index.html @@ -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" > + + @@ -35,11 +44,29 @@ > + + + + + + diff --git a/scripts/interface/Interface.js b/scripts/interface/Interface.js index 342666c..66f0509 100644 --- a/scripts/interface/Interface.js +++ b/scripts/interface/Interface.js @@ -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)); diff --git a/scripts/interface/Interface_spec.js b/scripts/interface/Interface_spec.js index bfa698a..02f2865 100644 --- a/scripts/interface/Interface_spec.js +++ b/scripts/interface/Interface_spec.js @@ -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: [], diff --git a/scripts/sequence/CodeMirrorHints.js b/scripts/sequence/CodeMirrorHints.js new file mode 100644 index 0000000..2179d2a --- /dev/null +++ b/scripts/sequence/CodeMirrorHints.js @@ -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') ? '' : 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, + }; +}); diff --git a/scripts/sequence/CodeMirrorMode.js b/scripts/sequence/CodeMirrorMode.js index 84ba0d1..de41f5b 100644 --- a/scripts/sequence/CodeMirrorMode.js +++ b/scripts/sequence/CodeMirrorMode.js @@ -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', suggest: true, then: { '': CM_TEXT_TO_END, - ':': {type: 'operator', then: {'': CM_TEXT_TO_END}}, + }}, + '\n': CM_END, + }}, + '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, + }}, + }}, + '\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, }}, }}, - 'repeat': {type: 'keyword', then: { - '': CM_TEXT_TO_END, - ':': {type: 'operator', then: {'': CM_TEXT_TO_END}}, + 'state': {type: 'keyword', suggest: 'state over ', then: { + 'over': {type: 'keyword', suggest: true, then: { + '': CM_AGENT_LIST_TO_TEXT, + }}, }}, - '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}}, + 'text': {type: 'keyword', suggest: true, then: { + 'left': CM_NOTE_LSIDE, + 'right': CM_NOTE_RSIDE, }}, - 'state': {type: 'keyword', then: { - 'over': {type: 'keyword', then: {'': CM_IDENT_LIST_TO_TEXT}}, - }}, - '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: { + '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; } diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index a3c2304..e0e67a8 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -247,7 +247,7 @@ define(['core/ArrayUtilities'], (array) => { meta: { title: meta.title, }, - agents: this.agents, + agents: this.agents.slice(), stages: globals.stages, }; } diff --git a/scripts/sequence/Parser.js b/scripts/sequence/Parser.js index 207aab8..affa291 100644 --- a/scripts/sequence/Parser.js +++ b/scripts/sequence/Parser.js @@ -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 = []; diff --git a/scripts/stubs/codemirror-comment.js b/scripts/stubs/codemirror-comment.js new file mode 100644 index 0000000..752e825 --- /dev/null +++ b/scripts/stubs/codemirror-comment.js @@ -0,0 +1,5 @@ +define([], () => { + 'use strict'; + + return null; +}); diff --git a/scripts/stubs/codemirror-show-hint.js b/scripts/stubs/codemirror-show-hint.js new file mode 100644 index 0000000..752e825 --- /dev/null +++ b/scripts/stubs/codemirror-show-hint.js @@ -0,0 +1,5 @@ +define([], () => { + 'use strict'; + + return null; +}); diff --git a/scripts/stubs/codemirror-trailingspace.js b/scripts/stubs/codemirror-trailingspace.js new file mode 100644 index 0000000..752e825 --- /dev/null +++ b/scripts/stubs/codemirror-trailingspace.js @@ -0,0 +1,5 @@ +define([], () => { + 'use strict'; + + return null; +}); diff --git a/scripts/stubs/codemirror.js b/scripts/stubs/codemirror.js index 2b6b067..addbf19 100644 --- a/scripts/stubs/codemirror.js +++ b/scripts/stubs/codemirror.js @@ -13,6 +13,7 @@ define([], () => { } CodeMirror.defineMode = () => null; + CodeMirror.registerHelper = () => null; return CodeMirror; }); diff --git a/styles/main.css b/styles/main.css index 92ca412..f163091 100644 --- a/styles/main.css +++ b/styles/main.css @@ -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%; diff --git a/test.htm b/test.htm index 29359c3..0c5e630 100644 --- a/test.htm +++ b/test.htm @@ -57,7 +57,10 @@ data-integrity="sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8=" > - + + + +