diff --git a/xC-gen-proto-go.awk b/xC-gen-proto-go.awk index 1880de7..1a64eb8 100644 --- a/xC-gen-proto-go.awk +++ b/xC-gen-proto-go.awk @@ -382,7 +382,8 @@ function codegen_union(name, cg, gotype, tagfield, tagvar) { print "}" print "" - print "func (u *" gotype ") MarshalJSON() ([]byte, error) {" + # This cannot be a pointer method, it wouldn't work recursively. + print "func (u " gotype ") MarshalJSON() ([]byte, error) {" print "\treturn json.Marshal(u.Interface)" print "}" print "" diff --git a/xC-proto b/xC-proto index 54ca6b8..e985fdb 100644 --- a/xC-proto +++ b/xC-proto @@ -104,6 +104,8 @@ struct EventMessage { } items<>; case BUFFER_CLEAR: string buffer_name; + + // Restriction: command_seq is strictly increasing, across both of these. case ERROR: u32 command_seq; string error; diff --git a/xP/public/xP.js b/xP/public/xP.js index f21683c..fd3a0ae 100644 --- a/xP/public/xP.js +++ b/xP/public/xP.js @@ -1,5 +1,175 @@ // Copyright (c) 2022, Přemysl Eric Janouch
// SPDX-License-Identifier: 0BSD +'use strict' + +// ---- RPC -------------------------------------------------------------------- + +class RelayRpc extends EventTarget { + constructor(url) { + super() + this.url = url + this.commandSeq = 0 + } + + connect() { + // We can't close the connection immediately, as that queues a task. + if (this.ws !== undefined) + throw "Already connecting or connected" + + return new Promise((resolve, reject) => { + let ws = this.ws = new WebSocket(this.url) + ws.onopen = event => { + this._initialize() + resolve() + } + // It's going to be code 1006 with no further info. + ws.onclose = event => { + reject() + this.ws = undefined + } + }) + } + + _initialize() { + this.ws.onopen = undefined + this.ws.onmessage = event => { + this._process(event.data) + } + this.ws.onerror = event => { + this.dispatchEvent(new CustomEvent('error')) + } + this.ws.onclose = event => { + let message = "Connection closed: " + + event.code + " (" + event.reason + ")" + for (const seq in this.promised) + this.promised[seq].reject(message) + + this.ws = undefined + this.dispatchEvent(new CustomEvent('close', { + detail: {message, code: event.code, reason: event.reason}, + })) + + // Now connect() can be called again. + } + + this.promised = {} + } + + _process(data) { + console.log(data) + + if (typeof data !== 'string') + throw "Binary messages not supported" + + let message = JSON.parse(data) + if (typeof message !== 'object') + throw "Invalid message" + let e = message.data + if (typeof e !== 'object') + throw "Invalid message" + + switch (e.event) { + case 'Error': + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].reject(e.error) + else + console.error("Unawaited error") + break + case 'Response': + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].resolve(e.data) + else + console.error("Unawaited response") + break + default: + if (typeof e.event !== 'string') + throw "Invalid event tag" + + this.dispatchEvent(new CustomEvent(e.event, {detail: e})) + + // Minor abstraction layering violation. + m.redraw() + return + } + + delete this.promised[e.commandSeq] + for (const seq in this.promised) { + // We don't particularly care about wraparound issues. + if (seq >= e.commandSeq) + continue + + this.promised[seq].reject("No response") + delete this.promised[seq] + } + } + + send(params) { + if (this.ws === undefined) + throw "Not connected" + if (typeof params !== 'object') + throw "Method parameters must be an object" + + let seq = ++this.commandSeq + if (seq >= 1 << 32) + seq = this.commandSeq = 0 + + this.ws.send(JSON.stringify({commandSeq: seq, data: params})) + return new Promise((resolve, reject) => { + this.promised[seq] = {resolve, reject} + }) + } +} + +// ---- Event processing ------------------------------------------------------- + +// TODO: Probably reset state on disconnect, and indicate to user. +let rpc = new RelayRpc(proxy) +rpc.connect() + .then(result => { + rpc.send({command: 'Hello', version: 1}) + }) + +let buffers = new Map() +let bufferCurrent = undefined + +rpc.addEventListener('BufferUpdate', event => { + let e = event.detail, b = buffers.get(e.bufferName) + if (b === undefined) { + b = {lines: []} + buffers.set(e.bufferName, b) + } + // TODO: Update any buffer properties. +}) + +rpc.addEventListener('BufferRename', event => { + let e = event.detail + buffers.set(e.new, buffers.get(e.bufferName)) + buffers.delete(e.bufferName) +}) + +rpc.addEventListener('BufferRemove', event => { + let e = event.detail + buffers.delete(e.bufferName) +}) + +rpc.addEventListener('BufferActivate', event => { + let e = event.detail + bufferCurrent = e.bufferName + // TODO: Somehow scroll to the end of it immediately. + // TODO: Focus the textarea. +}) + +rpc.addEventListener('BufferLine', event => { + let e = event.detail, b = buffers.get(e.bufferName) + if (b !== undefined) + b.lines.push({when: e.when, rendition: e.rendition, items: e.items}) +}) + +rpc.addEventListener('BufferClear', event => { + let e = event.detail, b = buffers.get(e.bufferName) + if (b !== undefined) + b.lines.length = 0 +}) // --- Colours ----------------------------------------------------------------- @@ -33,69 +203,6 @@ function applyColor(fg, bg, inverse) { return style } -// ---- Event processing ------------------------------------------------------- - -// TODO: Probably reset state on disconnect, and indicate to user. -let socket = new WebSocket(proxy) - -let commandSeq = 0 -function send(command) { - socket.send(JSON.stringify({commandSeq: ++commandSeq, data: command})) -} - -socket.onopen = function(event) { - send({command: 'Hello', version: 1}) -} - -let buffers = new Map() -let bufferCurrent = undefined - -socket.onmessage = function(event) { - console.log(event.data) - - let e = JSON.parse(event.data).data - switch (e.event) { - case 'BufferUpdate': - { - let b = buffers.get(e.bufferName) - if (b === undefined) { - b = {lines: []} - buffers.set(e.bufferName, b) - } - // TODO: Update any buffer properties. - break - } - case 'BufferRename': - buffers.set(e.new, buffers.get(e.bufferName)) - buffers.delete(e.bufferName) - break - case 'BufferRemove': - buffers.delete(e.bufferName) - break - case 'BufferActivate': - bufferCurrent = e.bufferName - // TODO: Somehow scroll to the end of it immediately. - // TODO: Focus the textarea. - break - case 'BufferLine': - { - let b = buffers.get(e.bufferName) - if (b !== undefined) - b.lines.push({when: e.when, rendition: e.rendition, items: e.items}) - break - } - case 'BufferClear': - { - let b = buffers.get(e.bufferName) - if (b !== undefined) - b.lines.length = 0 - break - } - } - - m.redraw() -} - // ---- UI --------------------------------------------------------------------- let BufferList = { @@ -103,8 +210,8 @@ let BufferList = { let items = [] buffers.forEach((b, name) => { let attrs = { - onclick: e => { - send({command: 'BufferActivate', bufferName: name}) + onclick: event => { + rpc.send({command: 'BufferActivate', bufferName: name}) }, } if (name == bufferCurrent) @@ -213,26 +320,54 @@ let Buffer = { }, } +function onKeyDown(event) { + // TODO: And perhaps on other actions, too. + rpc.send({command: 'Active'}) + + // TODO: Cancel any current autocomplete. + + let textarea = event.currentTarget + switch (event.keyCode) { + case 9: + if (textarea.selectionStart !== textarea.selectionEnd) + return + rpc.send({ + command: 'BufferComplete', + bufferName: bufferCurrent, + text: textarea.value, + position: textarea.selectionEnd, + }).then(response => { + // TODO: Somehow display remaining options, or cycle through. + if (response.completions.length) + textarea.setRangeText(response.completions[0], + response.start, textarea.selectionEnd, 'end') + if (response.completions.length === 1) + textarea.setRangeText(' ', + textarea.selectionStart, textarea.selectionEnd, 'end') + }) + break; + case 13: + rpc.send({ + command: 'BufferInput', + bufferName: bufferCurrent, + text: textarea.value, + }) + textarea.value = '' + break; + default: + return + } + + event.preventDefault() +} + // TODO: This should be remembered across buffer switches, // and we'll probably have to intercept /all/ key presses. let Input = { view: vnode => { return m('textarea', { rows: 1, - onkeydown: e => { - // TODO: And perhaps on other actions, too. - send({command: 'Active'}) - if (e.keyCode !== 13) - return - - send({ - command: 'BufferInput', - bufferName: bufferCurrent, - text: e.currentTarget.value, - }) - e.preventDefault() - e.currentTarget.value = '' - }, + onkeydown: onKeyDown, }) }, }