// Copyright (c) 2022, Přemysl Eric Janouch // SPDX-License-Identifier: 0BSD // --- Colours ----------------------------------------------------------------- let palette = [ '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc', '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff', ] palette.length = 256 for (let i = 0; i < 216; i++) { let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6 r = !r ? '00' : (55 + 40 * r).toString(16) g = !g ? '00' : (55 + 40 * g).toString(16) b = !b ? '00' : (55 + 40 * b).toString(16) palette[16 + i] = `#${r}${g}${b}` } for (let i = 0; i < 24; i++) { let g = ('0' + (8 + i * 10).toString(16)).slice(-2) palette[232 + i] = `#${g}${g}${g}` } function applyColor(fg, bg, inverse) { if (inverse) [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0] let style = '' if (fg >= 0) style += `color: ${palette[fg]};` if (bg >= 0) style += `background-color: ${palette[bg]};` if (style) 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 = { view: vnode => { let items = [] buffers.forEach((b, name) => { let attrs = { onclick: e => { send({command: 'BufferActivate', bufferName: name}) }, } if (name == bufferCurrent) attrs.class = 'active' items.push(m('.item', attrs, name)) }) return m('.list', {}, items) }, } function linkify(text, attrs, a) { let re = new RegExp([ /https?:\/\//, /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/, /[^\[\](){}<>"'\s,.:]/, ].map(r => r.source).join(''), 'g') let end = 0, match while ((match = re.exec(text)) !== null) { if (end < match.index) a.push(m('span', attrs, text.substring(end, match.index))) a.push(m('a', {href: match[0], ...attrs}, match[0])) end = re.lastIndex } if (end < text.length) a.push(m('span', attrs, text.substring(end))) } let Content = { view: vnode => { let line = vnode.children[0] let content = [] switch (line.rendition) { case 'Indent': content.push(m('span.mark', {}, '')); break case 'Status': content.push(m('span.mark', {}, '–')); break case 'Error': content.push(m('span.mark.error', {}, '⚠')); break case 'Join': content.push(m('span.mark.join', {}, '→')); break case 'Part': content.push(m('span.mark.part', {}, '←')); break case 'Action': content.push(m('span.mark.action', {}, '✶')); break } let classes = new Set() let flip = c => { if (classes.has(c)) classes.delete(c) else classes.add(c) } let fg = -1, bg = -1, inverse = false line.items.forEach(item => { switch (item.kind) { case 'Text': linkify(item.text, { class: Array.from(classes.keys()).join(' '), style: applyColor(fg, bg, inverse), }, content) break case 'Reset': classes.clear() fg = bg = -1 inverse = false break case 'FgColor': fg = item.color break case 'BgColor': bg = item.color break case 'FlipInverse': inverse = !inverse break case 'FlipBold': flip('b'); break case 'FlipItalic': flip('i'); break case 'FlipUnderline': flip('u'); break case 'FlipCrossedOut': flip('s'); break case 'FlipMonospace': flip('m'); break } }) return m('.content', {}, content) }, } let Buffer = { view: vnode => { let lines = [] let b = buffers.get(bufferCurrent) if (b === undefined) return let lastDateMark = undefined b.lines.forEach(line => { let date = new Date(line.when) let dateMark = date.toLocaleDateString() if (dateMark !== lastDateMark) { lines.push(m('.date', {}, dateMark)) lastDateMark = dateMark } lines.push(m('.time', {}, date.toLocaleTimeString())) lines.push(m(Content, {}, line)) }) return m('.buffer-container', {}, [ m('.filler'), m('.buffer', {}, lines), ]) }, } // 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 = '' }, }) }, } let Main = { view: vnode => { return m('.xP', {}, [ m('.title', {}, "xP"), m('.middle', {}, [m(BufferList), m(Buffer)]), m('.status', {}, bufferCurrent), m(Input), ]) }, } // TODO: Buffer names should work as routes. window.addEventListener('load', () => { m.route(document.body, '/', { '/': Main, }) })