lib/js/terminal.js
/**
* This is example of usage of LIPS interprter with jQuery Terminal
*
* Features: tab completion
* parenthesis matching
* auto formatting (when copy paste)
*
* Copyright (C) Jakub T. Jankiewicz <https://jcubic.pl/me>
* Released under MIT license
*/
/* global jQuery, $, clearTimeout, setTimeout */
var terminal = (function($) {
return function({ selector, lips, dynamic = false, name = 'terminal' }, undefined) {
var position;
var timer;
var help = lips.env.get('help');
function doc(fn, doc) {
fn.__doc__ = doc;
return fn;
}
// -------------------------------------------------------------------------
var interpreter = lips.Interpreter('demo', {
stdout: lips.OutputPort(function() {
var args = Array.from(arguments);
args.forEach(function(arg) {
term.echo(String(arg), { newline: false });
});
}),
// ---------------------------------------------------------------------
stdin: lips.InputPort(function() {
setTimeout(() => {
// resume terminal when read called in REPL
term.resume();
}, 0);
return term.read('');
}),
// ---------------------------------------------------------------------
// :: help that don't format the output - right now not needed because
// :: stdout don't use formatters
// ---------------------------------------------------------------------
help: doc(new lips.Macro('help', function(code, { error }) {
const { evaluate, Pair, LSymbol, nil } = lips;
var new_code = new Pair(new LSymbol('__help'), code);
var doc = evaluate(new_code, { env: this, error });
if (doc !== undefined) {
term.echo(doc, { formatters: false });
}
}), help.__doc__),
// ---------------------------------------------------------------------
pprint: doc(function(arg) {
if (arg instanceof lips.Pair) {
arg = new lips.Formatter(arg.toString(true)).break().format();
if ($.terminal.prism) {
arg = $.terminal.prism('scheme', arg, { echo: true });
}
this.get('display').call(this, arg);
} else {
this.get('write').call(this, arg);
}
this.get('newline').call(this);
}, lips.env.get('pprint').__doc__),
// ---------------------------------------------------------------------
'stack-trace': doc(function() {
if (strace) {
term.echo(strace);
}
}, `(stack-trace)
Displays the stack trace of the last error.`),
// ---------------------------------------------------------------------
'display-error': doc(function(message) {
term.error(message);
}, lips.env.get('display-error').__doc__),
// ---------------------------------------------------------------------
// hack so (let ((x lambda)) (help x))
'__help': lips.env.get('help')
});
// -------------------------------------------------------------------------
// display+repr(true) is same as write but that defined in external file
// -------------------------------------------------------------------------
var display = interpreter.get('display');
var repr = interpreter.get('repr');
var strace;
var term = $(selector).terminal(function(code, term) {
// format before executing mainly for strings in function docs
//code = new lips.Formatter(code).format();
return interpreter.exec(code, dynamic).then(function(ret) {
ret.forEach(function(ret) {
if (ret !== undefined) {
display(repr(ret, true));
display("\n");
}
});
}).catch(function(e) {
if (!e) {
console.warn('Exception is not defined');
return;
}
var message = e.message || e;
term.error(message);
term.echo('[[;red;]Call ][[;#fff;](stack-trace)][[;red;] to see the stack]');
term.echo('[[;red;]Thrown exception is in global exception variable,\nuse ' +
'][[;#fff;](display exception.stack)][[;red;] to display JS stack trace]');
if (e.__code__) {
strace = e.__code__.map((line, i) => {
var prefix = `[${i + 1}]: `;
var formatter = new lips.Formatter(line);
var output = formatter.break().format({
offset: prefix.length
});
return prefix + output;
}).join('\n');
}
window.exception = e;
});
}, {
name,
prompt: 'lips> ',
enabled: false,
greetings: false,
keymap: {
SPACE: function(e, original) {
e.preventDefault();
e.stopPropagation();
original();
},
ENTER: function(e, original) {
var command = this.get_command();
try {
if (lips.balanced_parenthesis(command)) {
original();
} else {
var code = term.before_cursor();
var prompt = this.get_prompt();
var formatter = new lips.Formatter(code);
var i = formatter.indent({
indent: 2,
offset: prompt.length
});
this.insert('\n' + (new Array(i + 1).join(' ')));
}
} catch (e) {
this.echo(this.get_prompt() + command);
this.set_command('');
term.error(e.message);
}
}
},
onPaste: function(e) {
if (e.text) {
var code = e.text;
var command = this.get_command();
if (command) {
// we handle only copy paste into empty cmd
return e.text;
}
var prompt = this.get_prompt();
var output;
try {
var formatter = new lips.Formatter(code);
if (!code.match(/\n/)) {
formatter.break();
}
output = formatter.format({
offset: prompt.length
});
} catch (e) {
console.log(e);
// boken LIPS code
output = code;
}
return Promise.resolve(output);
}
},
mobileIngoreAutoSpace: [',', '.', ')'],
completionEscape: false,
wordAutocomplete: false,
keydown: function() {
if (position) {
term.set_position(position);
position = false;
}
},
keypress: function(e) {
var term = this;
function is_open(token) {
return ['(', '['].indexOf(token) !== -1;
}
function is_close(token) {
return [')', ']'].indexOf(token) !== -1;
}
if (is_close(e.key)) {
setTimeout(function() {
position = term.get_position();
var command = term.get_command().substring(0, position);
var len = command.split(/\n/)[0].length;
var tokens = lips.tokenize(command, true);
var count = 1;
var token;
var i = tokens.length - 1;
while (count > 0) {
token = tokens[--i];
if (!token) {
return;
}
if (is_open(token.token)) {
count--;
} else if (is_close(token.token)) {
count++;
}
}
if (is_open(token.token) && count === 0) {
clearTimeout(timer);
setTimeout(function() {
var offset = token.offset;
term.set_position(offset);
timer = setTimeout(function() {
term.set_position(position);
position = false;
}, 200);
}, 0);
}
}, 0);
} else {
position = false;
}
},
doubleTab: function(string, matches, echo_command) {
echo_command();
this.echo(matches.map(command => {
return lips.tokenize(command).pop();
}), {
formatters: false
});
},
completion: function(string) {
let tokens = lips.tokenize(this.before_cursor(), true);
tokens = tokens.map(token => token.token);
// Array.from is need to for jQuery terminal version <2.5.0
// when terminal is outside iframe and lips is inside
// jQuery Terminal was using instanceof that don't work between iframes
var env = Array.from(interpreter.get('env')().to_array());
if (!tokens.length) {
return env;
}
const last = tokens.pop();
if (last.trim().length) {
const globals = Object.getOwnPropertyNames(window);
const prefix = tokens.join('');
const re = new RegExp('^' + $.terminal.escape_regex(last));
var commands = env.concat(globals).filter(name => {
return re.test(name);
}).map(name => prefix + name);
return Array.from(commands);
}
}
});
function get_type($node) {
return $node.attr('class').split(' ').find(function(cls) {
return cls !== 'token';
});
}
function tooltips($node, get_name, position) {
const selector = '.token.function, .token.keyword, .token.operator, .token.builtin, .token.name';
$node.on('mouseover', selector, function() {
var self = $(this);
var lips = term.interpreter;
var name = get_name(self);
var ref = lips.__env__.ref(name);
var doc = ref && (ref.get(name).__doc__ || ref.doc(name));
if (doc) {
var cls = get_type(self);
const { top, left, right } = position(self);
tooltip.text(doc);
var help_width = tooltip.width();
var help_height = tooltip.height();
var term_height = term.height();
var term_width = term.width();
var term_offset = term.offset();
var self_offset = self.offset();
cls = ['__doc__'];
if (top > help_height) {
cls.push('top');
}
if (left > window.innerWidth - help_width) {
cls.push('right');
}
const color = self.css('--color');
tooltip.css({
'--left': left,
'--top': top,
'--color': contrast_color(color) || 'black',
'--background': color,
'--right': right
}).addClass(cls.join(' '));
}
}).on('mouseout', '.token', function() {
hide_tooltip();
}).on('mousemove', '.terminal-scroller', function(e) {
if ($(e.target).is('.terminal-scroller')) {
hide_tooltip();
}
});
}
// ref: https://codepen.io/ikarium/pen/yXPQpo MIT
function contrast_color(rgba){
rgba = rgba.match(/\d+/g);
if (!rgba) {
return;
}
if ((rgba[0] * 0.299) + (rgba[1] * 0.587) + (rgba[2] * 0.114) > 186) {
return 'black';
} else {
return 'white';
}
}
const tooltip = $('<div class="terminal terminal-external terminal-tooltip"/>')
.appendTo('body');
tooltips(term, $node => $node.attr('data-text'), function($node) {
const div = $node.closest('div');
let last = $node, first = $node;
if ($node.closest('.cmd').length) {
const cls = get_type($node);
if ($node.next().is('.' + cls)) {
last = $node.nextUntil(':not(.' + cls + ')').last();
}
if ($node.prev().is('.' + cls)) {
first = $node.prevUntil(':not(.' + cls + ')').last();
}
}
const top = div.offset().top;
const left = first.offset().left;
const right = last.offset().left + last.width();
return { top, right, left };
});
function hide_tooltip() {
tooltip.removeClass('__doc__ top right').text('');
}
term.interpreter = interpreter;
term.tooltips = tooltips;
interpreter.set('term', term);
return term;
};
})(jQuery);