about summary refs log tree commit diff stats
path: root/html/squine.html
diff options
context:
space:
mode:
Diffstat (limited to 'html/squine.html')
-rw-r--r--html/squine.html1108
1 files changed, 1108 insertions, 0 deletions
diff --git a/html/squine.html b/html/squine.html
new file mode 100644
index 0000000..cd2c0e1
--- /dev/null
+++ b/html/squine.html
@@ -0,0 +1,1108 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <!--
+MIT License
+
+Copyright (c) m15o
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+-->
+    <meta charset="utf-8">
+    <title>squine</title>
+</head>
+<style>
+    html, body, #app, .column, textarea, table, td {
+        height: 100%;
+    }
+
+    body {
+        padding: 0;
+        margin: 0;
+        background: linear-gradient(to bottom, #e4d16f, #d5363d);
+    }
+
+    header {
+        display: flex;
+        border-bottom: 1px solid;
+        border-top: 1px solid;
+    }
+
+    header > * {
+        border-right: 1px solid;
+    }
+
+    .column {
+        display: flex;
+        flex-direction: column;
+        min-width: 10px;
+    }
+
+    article, section {
+        display: flex;
+        flex-direction: column;
+        overflow: hidden;
+    }
+
+    .name {
+        box-sizing: border-box;
+        width: 100%;
+        padding: 2px 5px;
+        margin: 0;
+        outline: none;
+        border: 0;
+        border-right: 1px solid;
+        background-color: #fffad6;
+    }
+
+    #search {
+        display: none;
+    }
+
+    #search.searching {
+        display: block;
+        position: fixed;
+        top: 0;
+        left: 0;
+        right: 0;
+        background-color: darkblue;
+        color: white;
+    }
+
+    textarea {
+        box-sizing: border-box;
+        padding: 5px;
+        margin: 0;
+        border: 0;
+        outline: none;
+        resize: none;
+        background-color: #fff9e9;
+    }
+
+    .action {
+        padding: 2px 5px;
+        cursor: pointer;
+    }
+
+    .back, .forward {
+        background-color: silver;
+    }
+
+    .hist {
+        background-color: inherit;
+    }
+
+    .folded article, .folded .h-resize, .maxed .h-resize {
+        display: none;
+    }
+
+    .maxed article {
+        display: flex;
+    }
+
+    .folded .maxed, .maxed article {
+        flex-grow: 1;
+    }
+
+    section:last-child, section:last-child article {
+        flex-grow: 1;
+    }
+
+    .folded section {
+        flex-grow: 0;
+    }
+
+    table {
+        width: 100%;
+        border-collapse: collapse;
+    }
+
+    td {
+        padding: 0;
+        border: 1px solid;
+    }
+
+    .menu > * {
+        padding: 0 4px;
+        cursor: pointer;
+    }
+
+    .menu > *:hover {
+        background-color: #dfad6a;
+    }
+
+    .active-p .content {
+        background-color: white;
+    }
+
+    .active {
+        background-color: #e5d575;
+    }
+
+    .col {
+        position: relative;
+    }
+
+    .h-resize:hover, .resize:hover {
+        background-color: rebeccapurple;
+    }
+
+    .resize {
+        width: 2px;
+        background-color: silver;
+        cursor: col-resize;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+    }
+
+    #settings {
+        display: none;
+    }
+
+    #settings.visible {
+        display: table-row;
+    }
+
+    .dirty .save {
+        background-color: aquamarine;
+    }
+
+    .h-resize {
+        height: 2px;
+        background-color: darkgrey;
+        cursor: row-resize;
+    }
+
+    .parse {
+        background-color: #dfad6a;
+    }
+</style>
+<body>
+<div id="app"></div>
+<script>
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Helpers
+
+    function $(elt) {
+        return document.querySelector(elt);
+    }
+
+    function $$(elt) {
+        return document.querySelectorAll(elt);
+    }
+
+    function $$active(elt) {
+        return $panel => ($panel && $panel.querySelector(elt)) || $('.active-p ' + elt);
+    }
+
+    function $name($panel) {
+        return $$active('.name')($panel);
+    }
+
+    function $content($panel) {
+        return $$active('.content')($panel);
+    }
+
+    function t$(e) {
+        return e.target;
+    }
+
+    function closest$(e, sel) {
+        return t$(e).closest(sel);
+    }
+
+    function $search() {
+        return $('#search');
+    }
+
+    function $panel() {
+        return $('.active-p');
+    }
+
+    function hasCls($elt, cls) {
+        return $elt.classList.contains(cls);
+    }
+
+    function date() {
+        return (new Date()).toLocaleDateString('en-CA');
+    }
+
+    function makeElt(elt, cls, attrs, html, value) {
+        let e = document.createElement(elt);
+        cls.forEach(c => e.classList.add(c));
+        Object.keys(attrs).forEach(k => e.setAttribute(k, attrs[k]));
+        html && (e.innerHTML = html);
+        value && (e.value = value);
+        return e;
+    }
+
+    function togCls(cls, sel, $elt) {
+        $$(sel).forEach((e) => e.classList.remove(cls));
+        $elt.classList.add(cls);
+    }
+
+    function setActive($section) {
+        togCls('active-p', 'section', $section);
+    }
+
+    function closeSearch() {
+        $search().classList.remove('searching');
+        $content().focus();
+    }
+
+    function refPane(name) {
+        let p;
+        $$('section').forEach(e => e.querySelector('.name').value.startsWith('+ref') && (p = e));
+        if (!p) {
+            p = createPane();
+        }
+        setActive(p);
+        load(name);
+        return p;
+    }
+
+    function runPane(content) {
+        let p;
+        save();
+        let old = $panel();
+        $$('section').forEach(e => e.querySelector('.name').value.startsWith('+run') && (p = e));
+        let err, rv;
+        storeKeys().forEach(k => {
+            if (k === 'main') return;
+            try {
+                global[k] = eval(global, parse(storeGet(k)), true);
+            } catch (e) {
+                err = `in ${k}: ${e}`
+            }
+        });
+        if (!p) {
+            p = createPane();
+            load('+run');
+        }
+        setActive(p);
+        if (err) {
+            $content().value = err;
+            return;
+        }
+        try {
+            $content().value = asString(exec(storeGet('main')));
+        } catch (e) {
+            $content().value = e;
+        }
+        setActive(old);
+        $content().focus();
+    }
+
+
+    // function runPane(content) {
+    //     let p;
+    //     $$('section').forEach(e => e.querySelector('.name').value.startsWith('+run') && (p = e));
+    //     let err, rv;
+    //     try {
+    //         if ($name().value !== 'main') {
+    //             global[$name().value] = eval(global, parse(content), true);
+    //             rv = asString(global[$name().value]);
+    //         }
+    //     } catch (e) {
+    //         err = e;
+    //     }
+    //
+    //     if (!p) {
+    //         p = createPane();
+    //         load('+run');
+    //     }
+    //     setActive(p);
+    //     if (err) {
+    //         $content().value = err;
+    //         return;
+    //     }
+    //     if (rv) {
+    //         $content().value = rv;
+    //         return;
+    //     }
+    //     try {
+    //         $content().value = asString(exec(content));
+    //     } catch (e) {
+    //         $content().value = e;
+    //     }
+    // }
+
+    function isChordNav(e) {
+        return e.metaKey || e.ctrlKey;
+    }
+
+    function isChordNavBlank(e) {
+        return isChordNav(e) && e.altKey;
+    }
+
+    function navBlank(name) {
+        findOrCreatePane(name);
+    }
+
+    function debounce(fn, ms) {
+        let timeout;
+        return (...args) => {
+            clearTimeout(timeout);
+            timeout = setTimeout(() => fn(...args), ms);
+        }
+    }
+
+    function basename(path) {
+        const n = path.split('/').pop();
+        return n.split('.').shift();
+    }
+
+    function download(data, name, mime) {
+        const link = document.createElement('a');
+        link.download = name;
+        link.href = window.URL.createObjectURL(new Blob([data], {type: mime}));
+        document.body.appendChild(link);
+        link.click();
+        document.body.removeChild(link);
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // storage
+
+    let store = {};
+
+    function storeSet(k, v) {
+        if (isStoreKey(k) && v === storeGet(k, v)) return;
+        $('#app').classList.add('dirty');
+        store[k] = v;
+    }
+
+    function storeGet(k) {
+        return store[k] || '';
+    }
+
+    function storeDel(k) {
+        delete store[k];
+    }
+
+    function storeKeys(sorted) {
+        let rv = Object.keys(store);
+        if (sorted) return rv.sort();
+        return rv;
+    }
+
+    function isStoreKey(k) {
+        return storeKeys().includes(k);
+    }
+
+    function storeClear() {
+        store = {};
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Core
+
+    function save() {
+        let name = $name().value;
+        let content = $content().value;
+        $panel().dataset['name'] = name;
+        if (!name || name === '+run') return;
+        storeSet(name, content);
+        refresh();
+    }
+
+    const saveD = debounce(save, 200);
+
+    function updateHistory(name) {
+        let cname = $panel().dataset['name'];
+        if (!name || name === cname) return;
+        hpush(hist().back, cname);
+        hist().forward = [];
+    }
+
+    function ls(prefix) {
+        return storeKeys(true).filter(k => !prefix || k.startsWith(prefix)).join('\n');
+    }
+
+    function orph() {
+        return storeKeys().map(k => {
+            const v = storeGet(k);
+            const m = [...v.matchAll(/\[\[([\w\.\-]+)\]\]/g)].map(match => match[1]);
+            const r = m.filter(e => !storeGet(e));
+            if (!r.length) return null;
+            return ['[[' + k + ']]', '----------', ...r.map(e => '[[' + e + ']]'), ''].join('\n');
+        }).filter(Boolean).join('\n');
+    }
+
+    function load(name, noHist) {
+        if (!name) {
+            $name().value = '';
+            $content().value = '';
+            return;
+        }
+        !noHist && updateHistory(name)
+        if (name === '+orph') {
+            $content().value = orph();
+        } else if (name.startsWith('+ls')) {
+            $content().value = ls(name.split(':')[1]);
+        } else if (name.startsWith('+search')) {
+            $content().value = name.split(':')[1] ? lookup(name.split(':')[1]) : '';
+        } else if (name.startsWith('+ref')) {
+            $content().value = name.split(':')[1] ? lookup('[[' + name.split(':')[1] + ']]') : '';
+        } else {
+            $content().value = storeGet(name);
+        }
+        $name().value = name;
+        $panel().dataset['name'] = name;
+        $panel().querySelector('.back').classList.toggle('hist', !!hist().back.length);
+        $panel().querySelector('.forward').classList.toggle('hist', !!hist().forward.length);
+    }
+
+    function refresh() {
+        let $elt = $('#files');
+        $elt.innerHTML = '';
+        storeKeys().forEach(k => $elt.appendChild(makeElt('option', [], {}, null, k)));
+        $elt = $('#globals');
+        $elt.innerHTML = '';
+        Object.keys(global).forEach(k => $elt.appendChild(makeElt('option', [], {}, null, k)));
+    }
+
+    let paneID = 0;
+
+    function createHistory(paneID) {
+        history[paneID] = {back: [], forward: []};
+    }
+
+    function removeHistory(paneID) {
+        delete history[paneID];
+    }
+
+    function deletePane($pane) {
+        removeHistory($pane.dataset['id']);
+        $pane.remove();
+    }
+
+    function createPane() {
+        const header = document.createElement('header');
+        header.append(
+            makeElt('span', ['back', 'action'], {}, '<'),
+            makeElt('span', ['forward', 'action'], {}, '>'),
+            makeElt('input', ['name'], {list: 'files', autocomplete: 'off'}),
+            makeElt('span', ['max', 'action'], {}, '+'),
+            makeElt('span', ['move', 'action'], {}, '~'),
+            makeElt('span', ['close', 'action'], {}, 'x')
+        );
+        const id = paneID++;
+        const section = document.createElement('section');
+        const article = document.createElement('article');
+        article.append(makeElt('textarea', ['content'], {spellcheck: false, onkeyup: 'saveD()'}));
+        section.setAttribute('data-id', '' + id);
+        section.append(
+            header,
+            makeElt('div', ['parse'], {}),
+            article,
+            makeElt('div', ['h-resize'], {})
+        );
+        createHistory(id);
+        $(".column.active").appendChild(section);
+        setActive(section);
+        $content().focus();
+        return section;
+    }
+
+    function lookup(str) {
+        return storeKeys(true).map(k => {
+            const v = storeGet(k);
+            const m = v.split('\n').filter(l => l.includes(str));
+            if (!m.length) return null;
+            return ['[[' + k + ']]', '----------', ...m, ''].join('\n');
+        }).filter(Boolean).join('\n');
+    }
+
+    function mv(before, after) {
+        return storeKeys().forEach(k => {
+            const v = storeGet(k);
+            storeSet(k, v.replaceAll('[[' + before + ']]', '[[' + after + ']]'))
+        })
+    }
+
+    function link(textarea) {
+        const text = textarea.value;
+        const pos = textarea.selectionStart;
+        let start, end;
+
+        for (start = pos - 1; start > -1 && /[^\s\(\)]/.test(text[start]); start--) {
+        }
+
+        for (end = pos; end < text.length && /[^\s\(\)]/.test(text[end]); end++) {
+        }
+
+        return text.substring(start + 1, end);
+    }
+
+    function insert(textarea, text) {
+        const start = textarea.selectionStart;
+        const end = textarea.selectionEnd;
+        const before = textarea.value.substring(0, start);
+        const after = textarea.value.substring(end, textarea.value.length);
+
+        textarea.value = before + text + after;
+        textarea.selectionStart = textarea.selectionEnd = start + text.length;
+    }
+
+    const write = text => document.execCommand('insertText', false, text);
+
+    const history = {};
+
+    function hist() {
+        return history[$panel().dataset['id']];
+    }
+
+    function hpush(h, name) {
+        if (h[h.length - 1] === name) return;
+        h.push(name);
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Menu
+
+    function menuNew() {
+        createPane();
+    }
+
+    function menuLs() {
+        $name() || createPane();
+        load('+ls');
+    }
+
+    function menuReset() {
+        if (confirm('delete everything?')) {
+            storeClear();
+        }
+    }
+
+    function menuRun() {
+        runPane($content().value);
+    }
+
+    function menuMv() {
+        let prev = $panel().dataset['name'];
+        if (prev === $name().value) return;
+        mv(prev, $name().value);
+        save();
+        storeDel(prev);
+        $prevPanel = $panel();
+        $$('section').forEach($pane => {
+            setActive($pane);
+            load($name().value, false);
+        })
+        setActive($prevPanel);
+        refresh();
+    }
+
+    function quine() {
+        const regex = /let store = (.*)/;
+        return ['<!DOCTYPE html>',
+            `<head>${document.head.innerHTML}</head>`,
+            '<body>',
+            '<div id="app"></div>',
+            `<script>${$('script').innerHTML.replace(regex, "let store = " + JSON.stringify(store)
+                .replaceAll('</' + 'script', "' + '</' + 'script' + '") + ';') + '</'}script>`,
+            '</body>'].join('\n');
+    }
+
+    function menuSave() {
+        $('#app').classList.remove('dirty');
+        download(quine(), basename(window.location.href) + '.html', 'text/html');
+    }
+
+    function menuExport() {
+        download(JSON.stringify(store), basename(window.location.href) + '.json', 'text/json');
+    }
+
+    function menuDel() {
+        let name = $name().value;
+        if (name && confirm('delete ' + name + '?')) {
+            storeDel(name);
+            refresh();
+            deletePane($panel());
+        }
+    }
+
+    function menuSettings() {
+        $('#settings').classList.toggle('visible');
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Pane actions
+
+    function paneFold(e) {
+        closest$(e, 'section').classList.toggle('folded');
+    }
+
+    function paneMax(e) {
+        if (closest$(e, 'section').classList.contains('maxed')) {
+            closest$(e, 'section').classList.remove('maxed');
+            closest$(e, '.column').classList.remove('folded');
+        } else {
+            closest$(e, '.column').querySelectorAll('section').forEach(e => e.classList.remove("maxed"));
+            closest$(e, 'section').classList.add("maxed");
+            closest$(e, '.column').classList.add('folded');
+        }
+    }
+
+    function paneMove(e) {
+        let $c;
+        $$('.column').forEach(c => {
+            if (c !== closest$(e, '.column')) $c = c;
+        });
+        $c.appendChild(closest$(e, 'section'));
+    }
+
+    function paneClose(e) {
+        deletePane(closest$(e, 'section'));
+        if ($('section')) {
+            setActive($('section'));
+            $content().focus;
+        }
+    }
+
+    function paneHist(from, to) {
+        return function (e) {
+            if (!from.length) return;
+            let name = from.pop();
+            if (isChordNavBlank(e)) {
+                from.push(name);
+                createPane();
+            } else if (isChordNav(e)) {
+                from.push(name);
+                setActive($prevPanel);
+                load(name);
+                $content().focus();
+            } else {
+                hpush(to, $panel().dataset['name']);
+            }
+            load(name, true);
+        }
+    }
+
+    function findOrCreatePane(name) {
+        let p;
+        $$('section').forEach(e => e.querySelector('.name').value === name && (p = e));
+        if (!p) {
+            p = createPane();
+        }
+        setActive(p);
+        load(name);
+        return p;
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Scheme interpreter
+
+    const global = {};
+    const arity = (fn, n) => (s, env) => {
+        if (collect(cdr(s)).length !== n) throw new Error(`${asString(car(s))}: Wrong number of arguments`);
+        return fn(s, env);
+    }
+    const primitive = {
+        define: arity((s, env) => {
+            return env[cadr(s)] = eval(env, cadr(cdr(s)));
+        }, 2),
+        quote: arity((s, env) => cadr(s), 1),
+        car: arity((s, env) => {
+            let rv = eval(env, cadr(s));
+            if (rv === null) throw new Error('You cannot ask for the car of the empty list');
+            else if (isAtom(rv)) throw new Error('You cannot ask for the car of an atom');
+            return car(rv)
+        }, 1),
+        cdr: arity((s, env) => {
+            let rv = eval(env, cadr(s));
+            if (rv === null) throw new Error('You cannot ask for the cdr of the empty list');
+            if (isAtom(rv)) throw new Error('You cannot ask for the cdr of an atom');
+            return cdr(rv)
+        }, 1),
+        cons: arity((s, env) => {
+            let r = eval(env, cadr(cdr(s)));
+            if (!isList(r)) throw new Error('Second argument of cons must be a list');
+            return cons(eval(env, cadr(s)), r);
+        }, 2),
+        'null?': arity((s, env) => {
+            let r = eval(env, cadr(s));
+            if (!isList(r)) throw new Error('null? is defined only for lists');
+            return r === null;
+        }, 1),
+        'zero?': arity((s, env) => {
+            let r = eval(env, cadr(s));
+            if (!isAtom(r)) throw new Error('zero? is defined only for atoms');
+            return r === 0;
+        }, 1),
+        'atom?': arity((s, env) => {
+            return isAtom(eval(env, cadr(s)));
+        }, 1),
+        'number?': arity((s, env) => {
+            return isNumber(eval(env, cadr(s)));
+        }, 1),
+        'eq?': arity((s, env) => {
+            let l = eval(env, cadr(s));
+            let r = eval(env, cadr(cdr(s)));
+            if (!isAtom(l) || isNumber(l) || !isAtom(r) || isNumber(r)) throw new Error('eq? takes two non-numeric atoms');
+            return l === r;
+        }, 2),
+        or: arity((s, env) => {
+            return eval(env, cadr(s)) || eval(env, cadr(cdr(s)));
+        }, 2),
+        and: arity((s, env) => {
+            return eval(env, cadr(s)) && eval(env, cadr(cdr(s)));
+        }, 2),
+        add1: arity((s, env) => eval(env, cadr(s)) + 1, 1),
+        sub1: arity((s, env) => eval(env, cadr(s)) - 1, 1),
+        lambda: arity((s, env) => {
+            return {
+                args: collect(cadr(s)), body: cadr(cdr(s)),
+                env: Object.fromEntries(Object.entries(env))
+            }
+        }, 2),
+        letrec: arity((s, env) => {
+            let f = eval(env, car(cdr(car(cadr(s)))));
+            f.env[car(car(car(cdr(s))))] = f;
+            return eval(f.env, car(cdr(cdr(s))));
+        }, 2),
+        cond: (s, env) => {
+            let b = collect(cdr(s));
+            for (let i = 0; i < b.length; i++) {
+                if (car(b[i]) === 'else' || eval(env, car(b[i])))
+                    return eval(env, cadr(b[i]));
+            }
+        }
+    }
+    const cons = (car, cdr) => [car, cdr];
+    const car = c => c[0];
+    const cdr = c => c[1];
+    const cadr = c => car(cdr(c));
+    const isAtom = s => s !== null && !Array.isArray(s);
+    const isList = s => Array.isArray(s) || s === null;
+    const isNumber = s => typeof s === 'number';
+    const parse = src => ast(src.replaceAll('(', ' ( ').replaceAll(')', ' ) ').replaceAll(/;.*$/gm, '').split(/\s/).filter(Boolean));
+    const exec = src => eval(global, parse(src), true);
+
+    function ast(tokens, d = {depth: 0}) {
+        if (!tokens.length) {
+            if (d.depth > 0) throw new Error('Unexpected EOF!');
+            else return null;
+        }
+        let t = tokens.shift();
+        if (t === ')') {
+            if (d.depth-- === 0) throw new Error('Unexpected closing parenthesis');
+            return null;
+        } else if (t === '(') {
+            d.depth++;
+            return cons(ast(tokens, d), ast(tokens, d));
+        } else if (t[0] === '#') {
+            if (tokens.length) return cons(t[1] === 't', ast(tokens, d));
+            return t[1] === 't';
+        } else if (isNaN(t)) {
+            if (tokens.length) return cons(t, ast(tokens, d));
+            return t;
+        }
+        if (tokens.length) return cons(+t, ast(tokens, d));
+        return +t;
+    }
+
+    function collect(s) {
+        if (!s) return [];
+        return [car(s), ...collect(cdr(s))];
+    }
+
+    function eval(env, s, isSrc) {
+        if (s === null) throw new Error('Evaluating empty list');
+        if (isAtom(s)) {
+            if (isNaN(s)) {
+                if (env[s] !== undefined) return env[s];
+                else if (Object.keys(primitive).includes(s)) return s;
+                throw new Error('Undefined variable ' + s);
+            }
+            return s;
+        } else if (isSrc) {
+            let rv = eval(env, car(s));
+            if (cdr(s)) return eval(env, cdr(s), isSrc);
+            return rv;
+        }
+        let proc = eval(env, car(s));
+        if (Object.keys(primitive).includes(proc || car(s))) {
+            return primitive[proc || car(s)](s, env)
+        }
+        try {
+            let args = {};
+            collect(cdr(s)).forEach((a, i) => {
+                args[proc.args[i]] = eval(env, a);
+            });
+            return arity((_, env) => eval(env, proc.body), proc.args.length)(s, Object.assign({}, env, proc.env, args));
+        } catch (e) {
+            throw new Error(`${asString(car(s))}: ${e}`);
+        }
+    }
+
+    function asString(s) {
+        function build(s, acc = [], nl = true) {
+            if (s === null) {
+                acc.push('()');
+                return acc;
+            } else if (s.body) {
+                acc.push('<proc (' + s.args.join(' ') + ')>');
+                return acc;
+            } else if (isAtom(s)) {
+                acc.push(s);
+                return acc;
+            } else {
+                nl && acc.push('(');
+                build(s[0], acc, true);
+                s[1] && build(s[1], acc, false);
+                nl && acc.push(')');
+            }
+            return acc;
+        }
+
+        return build(s).join(' ').replaceAll('( ', '(').replaceAll(' )', ')');
+    }
+
+    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+    // Listeners
+
+    let $prevPanel;
+    let $resize;
+    let $hresize;
+
+    function handleSearch(e) {
+        e.preventDefault();
+        e.stopPropagation();
+        if (isChordNavBlank(e)) {
+            navBlank(t$(e).value);
+            closeSearch();
+        } else if (isChordNav(e)) {
+            if (!$content()) createPane();
+            closeSearch();
+            load(t$(e).value);
+        } else {
+            if (!$content()) return;
+            closeSearch();
+            if (!isStoreKey(t$(e).value)) {
+                storeSet(t$(e).value, '');
+                refresh();
+            }
+            write(t$(e).value);
+        }
+    }
+
+    document.getElementById('app').innerHTML = `
+ <datalist id="files"></datalist>
+ <datalist id="globals"></datalist>
+<table>
+    <tr>
+        <td colspan="2" class="menu" style="height: 0;">
+            <span class="new">new</span><span class="ls">ls</span><span class="mv">mv</span><span class="del">del</span><span class="save">save</span><span class="run">run</span><span class="settings">import</span><span class="export">export</span><span class="reset">reset</span>
+        </td>
+    </tr>
+    <tr id="settings">
+        <td colspan="2" class="menu" style="height: 0;">
+            <input type="file" id="import" value="">
+        </td>
+    </tr>
+    <tr>
+        <td class="col">
+            <div class="active column"></div>
+            <div class="resize"></div>
+        </td>
+        <td>
+            <div class="column"></div>
+        </td>
+    </tr>
+</table>
+<input list="files" spellcheck="false" id="search" autocomplete="off"/>
+    `;
+
+    document.addEventListener('mousedown', function (e) {
+        if (hasCls(t$(e), 'resize')) {
+            e.preventDefault();
+            $resize = closest$(e, 'td');
+        } else if (hasCls(t$(e), 'h-resize')) {
+            e.preventDefault();
+            $hresize = closest$(e, 'section').querySelector('article');
+        }
+        $prevPanel = $panel() || $prevPanel;
+        let section = closest$(e, 'section');
+        section && setActive(section);
+        let column = closest$(e, '.column');
+        if (column && (!e.altKey && !e.metaKey && !e.ctrlKey)) {
+            togCls('active', '.column', column);
+        }
+    });
+
+    document.addEventListener('mousemove', function (e) {
+        if ($resize) $resize.width = e.clientX;
+        else if ($hresize) {
+            const $sib = $hresize.closest('section').nextSibling;
+            const rect = $hresize.getBoundingClientRect();
+            let h = e.y - rect.top < 0 ? 0 : e.y - rect.top;
+            let diff = h - rect.height;
+            if ($sib && $sib.nextSibling) {
+                const $sArt = $sib.querySelector('article');
+                const sRect = $sArt.getBoundingClientRect();
+                if (sRect.height - diff <= 0) {
+                    $hresize.style.height = `${+$hresize.style.height.split('px')[0] + sRect.height}px`;
+                    $sArt.style.height = `0px`;
+                    return;
+                }
+                $sArt.style.height = `${sRect.height - diff}px`;
+            }
+            $hresize.style.height = `${h}px`;
+        }
+    })
+
+    document.addEventListener('mouseup', () => {
+        $resize = null;
+        $hresize = null;
+    });
+
+    document.addEventListener('click', function (e) {
+        // @formatter:off
+        switch (t$(e).classList[0]) {
+            case 'new': menuNew(); return;
+            case 'ls': menuLs(); return;
+            case 'reset': menuReset(); return;
+            case 'save': menuSave(); return;
+            case 'mv': menuMv(); return;
+            case 'del': menuDel(); return;
+            case 'settings': menuSettings(); return;
+            case 'run': menuRun(); return;
+            case 'export': menuExport(); return;
+            case 'close': paneClose(e); return;
+            case 'max': paneMax(e); return;
+            case 'move': paneMove(e); return;
+            case 'back': paneHist(hist().back, hist().forward)(e); return;
+            case 'forward': paneHist(hist().forward, hist().back)(e); return;
+        }
+        // @formatter:on
+        if (isChordNavBlank(e) && e.button === 0) {
+            link($content()) && navBlank(link($content()));
+        } else if (isChordNav(e) && e.button === 0) {
+            let name = link($content());
+            if (name) {
+                setActive($prevPanel);
+                load(name);
+                $content().focus();
+            }
+        }
+    });
+
+    window.addEventListener('beforeunload', e => {
+        if ($('.dirty')) {
+            e.preventDefault();
+            e.returnValue = true;
+        }
+    })
+
+    function offset(text) {
+        const stack = [];
+        let o = 0;
+        text.split('').forEach((c, i) => {
+            if (c === '(') stack.push({spaces: i - o, total: i});
+            else if (c === ')') stack.pop();
+            else if (c === '\n') o = i + 1;
+        });
+        return stack.pop() || 0;
+    }
+
+    const move = (e, pos) => e.setSelectionRange(pos, pos);
+    const caret = e => e.value.substring(0, e.selectionStart);
+    let last;
+
+    document.addEventListener('keydown', function (e) {
+        if (e.key === 'Enter') {
+            if (t$(e).id === 'search') {
+                handleSearch(e);
+            } else if (isChordNav(e)) {
+                $search().value = '';
+                $search().classList.add('searching');
+                $search().focus();
+            } else if (hasCls(e.target, 'name')) {
+                load($name().value);
+            } else {
+                e.preventDefault();
+                let s = offset(caret(t$(e)));
+                if (s.spaces === undefined) write('\n')
+                else write('\n' + Array(s.spaces + 3).join(' '));
+            }
+        } else if (e.key === 'Escape' && e.target.id === 'search') {
+            $search().classList.remove('searching');
+            $content().focus();
+        } else if (t$(e).classList[0] === 'content') {
+            const textarea = t$(e);
+            if (e.key === '(') {
+                e.preventDefault();
+                write('()');
+                move(textarea, textarea.selectionStart - 1);
+            }
+            if (e.key === '\'') {
+                e.preventDefault();
+                write('(quote )');
+                move(textarea, textarea.selectionStart - 1);
+            } else if (e.key === 'Tab') {
+                e.preventDefault();
+                let c = caret(textarea);
+                if (c[c.length - 1] === '(') {
+                    return move(textarea, last);
+                }
+                let o = offset(c);
+                if (o.spaces === undefined) return;
+                last = c.length;
+                move(textarea, o.total + 1);
+            } else if (e.key === 'r' && (e.metaKey || e.ctrlKey)) {
+                runPane(textarea.value);
+            }
+        }
+    });
+
+    document.getElementById('import').addEventListener('change', function (e) {
+        const file = e.target.files[0];
+        const r = new FileReader();
+        r.onload = e => {
+            let data = JSON.parse('' + e.target.result);
+            Object.keys(data).forEach(k => storeSet(k, data[k]));
+            refresh();
+        }
+        r.readAsText(file);
+        refresh();
+    });
+
+    document.addEventListener('keyup', function (e) {
+        if (t$(e).className === 'content') {
+            let $parse = closest$(e, 'section').querySelector('.parse');
+            try {
+                parse($content().value);
+                $parse.innerHTML = '';
+            } catch (e) {
+                $parse.innerHTML = e;
+            }
+        }
+    })
+
+    refresh();
+    createPane();
+    load((location.hash && location.hash.substring(1)) || 'main');
+</script>
+</body>
+</html>
\ No newline at end of file