Add Option to make Py-Terminal and Xterm.js (#1317)

* Add 'xterm' attribute in py-config using new validation

* Use screen reader mode

* Add `xtermReady` promise to allow users to away xterm.js init

* Guard against initializing a tag twice

* Add tests and doc
This commit is contained in:
Jeff Glass
2023-05-29 10:00:20 -05:00
committed by GitHub
parent 538aac9a28
commit 932756c7a0
7 changed files with 361 additions and 35 deletions

View File

@@ -35,7 +35,8 @@
"pyodide": "0.23.2",
"synclink": "0.2.4",
"ts-jest": "29.0.3",
"typescript": "5.0.4"
"typescript": "5.0.4",
"xterm": "^5.1.0"
}
},
"node_modules/@ampproject/remapping": {
@@ -6022,6 +6023,12 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
},
"node_modules/xterm": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.1.0.tgz",
"integrity": "sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==",
"dev": true
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -10469,6 +10476,12 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
},
"xterm": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.1.0.tgz",
"integrity": "sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==",
"dev": true
},
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -37,7 +37,8 @@
"pyodide": "0.23.2",
"synclink": "0.2.4",
"ts-jest": "29.0.3",
"typescript": "5.0.4"
"typescript": "5.0.4",
"xterm": "^5.1.0"
},
"dependencies": {
"basic-devtools": "^0.1.6",

View File

@@ -6,14 +6,18 @@ import { Plugin, validateConfigParameterFromArray } from '../plugin';
import { getLogger } from '../logger';
import { type Stdio } from '../stdio';
import { InterpreterClient } from '../interpreter_client';
import { Terminal as TerminalType } from 'xterm';
const logger = getLogger('py-terminal');
const knownPyTerminalTags: WeakSet<HTMLElement> = new WeakSet();
type AppConfigStyle = AppConfig & {
terminal?: string | boolean;
docked?: string | boolean;
terminal?: boolean | 'auto';
docked?: boolean | 'docked';
xterm?: boolean | 'xterm';
};
const logger = getLogger('py-terminal');
export class PyTerminalPlugin extends Plugin {
app: PyScriptApp;
@@ -36,19 +40,27 @@ export class PyTerminalPlugin extends Plugin {
possibleValues: [true, false, 'docked'],
defaultValue: 'docked',
});
validateConfigParameterFromArray({
config: config,
name: 'xterm',
possibleValues: [true, false, 'xterm'],
defaultValue: false,
});
}
beforeLaunch(config: AppConfigStyle) {
// if config.terminal is "yes" or "auto", let's add a <py-terminal> to
// the document, unless it's already present.
const { terminal: t, docked: d } = config;
const { terminal: t, docked: d, xterm: x } = config;
const auto = t === true || t === 'auto';
const docked = d === true || d === 'docked';
const xterm = x === true || x === 'xterm';
if (auto && $('py-terminal', document) === null) {
logger.info('No <py-terminal> found, adding one');
const termElem = document.createElement('py-terminal');
if (auto) termElem.setAttribute('auto', '');
if (docked) termElem.setAttribute('docked', '');
if (xterm) termElem.setAttribute('xterm', '');
document.body.appendChild(termElem);
}
}
@@ -57,7 +69,8 @@ export class PyTerminalPlugin extends Plugin {
// the Python interpreter has been initialized and we are ready to
// execute user code:
//
// 1. define the "py-terminal" custom element
// 1. define the "py-terminal" custom element, either a <pre> element
// or using xterm.js
//
// 2. if there is a <py-terminal> tag on the page, it will register
// a Stdio listener just before the user code executes, ensuring
@@ -70,48 +83,58 @@ export class PyTerminalPlugin extends Plugin {
//
// 4. (in the future we might want to add an option to start the
// capture earlier, but I don't think it's important now).
const PyTerminal = make_PyTerminal(this.app);
const PyTerminal = _interpreter.config.xterm ? make_PyTerminal_xterm(this.app) : make_PyTerminal_pre(this.app);
customElements.define('py-terminal', PyTerminal);
}
}
function make_PyTerminal(app: PyScriptApp) {
abstract class PyTerminalBaseClass extends HTMLElement implements Stdio {
autoShowOnNextLine: boolean;
isAuto() {
return this.hasAttribute('auto');
}
isDocked() {
return this.hasAttribute('docked');
}
setupPosition(app: PyScriptApp) {
if (this.isAuto()) {
this.classList.add('py-terminal-hidden');
this.autoShowOnNextLine = true;
} else {
this.autoShowOnNextLine = false;
}
if (this.isDocked()) {
this.classList.add('py-terminal-docked');
}
logger.info('Registering stdio listener');
app.registerStdioListener(this);
}
abstract stdout_writeline(msg: string): void;
abstract stderr_writeline(msg: string): void;
}
function make_PyTerminal_pre(app: PyScriptApp) {
/** The <py-terminal> custom element, which automatically register a stdio
* listener to capture and display stdout/stderr
*/
class PyTerminal extends HTMLElement implements Stdio {
class PyTerminalPre extends PyTerminalBaseClass {
outElem: HTMLElement;
autoShowOnNextLine: boolean;
connectedCallback() {
// should we use a shadowRoot instead? It looks unnecessarily
// complicated to me, but I'm not really sure about the
// implications
this.outElem = document.createElement('pre');
this.outElem.className = 'py-terminal';
this.outElem.classList.add('py-terminal');
this.appendChild(this.outElem);
if (this.isAuto()) {
this.classList.add('py-terminal-hidden');
this.autoShowOnNextLine = true;
} else {
this.autoShowOnNextLine = false;
}
if (this.isDocked()) {
this.classList.add('py-terminal-docked');
}
logger.info('Registering stdio listener');
app.registerStdioListener(this);
}
isAuto() {
return this.hasAttribute('auto');
}
isDocked() {
return this.hasAttribute('docked');
this.setupPosition(app);
}
// implementation of the Stdio interface
@@ -132,5 +155,121 @@ function make_PyTerminal(app: PyScriptApp) {
// end of the Stdio interface
}
return PyTerminal;
return PyTerminalPre;
}
declare const Terminal: typeof TerminalType;
function make_PyTerminal_xterm(app: PyScriptApp) {
/** The <py-terminal> custom element, which automatically register a stdio
* listener to capture and display stdout/stderr
*/
class PyTerminalXterm extends PyTerminalBaseClass {
outElem: HTMLDivElement;
_moduleResolved: boolean;
xtermReady: Promise<TerminalType>;
xterm: TerminalType;
cachedStdOut: Array<string>;
cachedStdErr: Array<string>;
_xterm_cdn_base_url = 'https://cdn.jsdelivr.net/npm/xterm@5.1.0';
constructor() {
super();
this.cachedStdOut = [];
this.cachedStdErr = [];
// While this is false, store writes to stdout/stderr to a buffer
// when the xterm.js is actually ready, we will "replay" those writes
// and set this to true
this._moduleResolved = false;
//Required to make xterm appear properly
this.style.width = '100%';
this.style.height = '100%';
}
async connectedCallback() {
//guard against initializing a tag twice
if (knownPyTerminalTags.has(this)) return;
knownPyTerminalTags.add(this);
this.outElem = document.createElement('div');
//this.outElem.className = 'py-terminal';
this.appendChild(this.outElem);
this.setupPosition(app);
this.xtermReady = this._setupXterm();
await this.xtermReady;
}
/**
* Fetch the xtermjs library from CDN an initialize it.
* @private
* @returns the associated xterm.js Terminal
*/
async _setupXterm() {
if (this.xterm == undefined) {
//need to initialize the Terminal for this element
// eslint-disable-next-line
// @ts-ignore
if (globalThis.Terminal == undefined) {
//load xterm module from cdn
//eslint-disable-next-line
//@ts-ignore
await import(this._xterm_cdn_base_url + '/lib/xterm.js');
const cssTag = document.createElement('link');
cssTag.type = 'text/css';
cssTag.rel = 'stylesheet';
cssTag.href = this._xterm_cdn_base_url + '/css/xterm.css';
document.head.appendChild(cssTag);
}
//Create xterm, add addons
this.xterm = new Terminal({ screenReaderMode: true, cols: 80 });
// xterm must only 'open' into a visible DOM element
// If terminal is still hidden, open during first write
if (!this.autoShowOnNextLine) this.xterm.open(this);
this._moduleResolved = true;
//Write out any messages output while xterm was loading
this.cachedStdOut.forEach((value: string): void => this.stdout_writeline(value));
this.cachedStdErr.forEach((value: string): void => this.stderr_writeline(value));
} else {
this._moduleResolved = true;
}
return this.xterm;
}
// implementation of the Stdio interface
stdout_writeline(msg: string) {
if (this._moduleResolved) {
this.xterm.writeln(msg);
//this.outElem.innerText += msg + '\n';
if (this.isDocked()) {
this.scrollTop = this.scrollHeight;
}
if (this.autoShowOnNextLine) {
this.classList.remove('py-terminal-hidden');
this.autoShowOnNextLine = false;
this.xterm.open(this);
}
} else {
//if xtermjs not loaded, cache messages
this.cachedStdOut.push(msg);
}
}
stderr_writeline(msg: string) {
this.stdout_writeline(msg);
}
// end of the Stdio interface
}
return PyTerminalXterm;
}

View File

@@ -1,3 +1,5 @@
import time
from playwright.sync_api import expect
from .support import PyScriptTest, skip_worker
@@ -144,3 +146,119 @@ class TestPyTerminal(PyScriptTest):
self.page.locator("button").click()
expect(term).to_be_visible()
assert term.get_attribute("docked") == ""
def test_xterm_function(self):
"""Test a few basic behaviors of the xtermjs terminal.
This test isn't meant to capture all of the behaviors of an xtermjs terminal;
rather, it confirms with a few basic formatting sequences that (1) the xtermjs
terminal is functioning/loaded correctly and (2) that output toward that terminal
isn't being escaped in a way that prevents it reacting to escape seqeunces. The
main goal is preventing regressions.
"""
self.pyscript_run(
"""
<py-config>
xterm = true
</py-config>
<py-script>
print("\x1b[33mYellow\x1b[0m")
print("\x1b[4mUnderline\x1b[24m")
print("\x1b[1mBold\x1b[22m")
print("\x1b[3mItalic\x1b[23m")
print("done")
</py-script>
"""
)
# Wait for "done" to actually appear in the xterm; may be delayed,
# since xtermjs processes its input buffer in chunks
last_line = self.page.get_by_text("done")
last_line.wait_for()
# Yes, this is not ideal. However, per http://xtermjs.org/docs/guides/hooks/
# "It is not possible to conclude, whether or when a certain chunk of data
# will finally appear on the screen," which is what we'd really like to know.
# By waiting for the "done" test to appear above, we get close, however it is
# possible for the text to appear and not be 'processed' (i.e.) formatted. This
# small delay should avoid that.
time.sleep(1)
rows = self.page.locator(".xterm-rows")
# The following use locator.evaluate() and getComputedStyle to get
# the computed CSS values; this tests that the lines are rendering
# properly in a better way than just testing whether they
# get the right css classes from xtermjs
# First line should be yellow
first_line = rows.locator("div").nth(0)
first_char = first_line.locator("span").nth(0)
color = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('color')"
)
assert color == "rgb(196, 160, 0)"
# Second line should be underlined
second_line = rows.locator("div").nth(1)
first_char = second_line.locator("span").nth(0)
text_decoration = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('text-decoration')"
)
assert "underline" in text_decoration
# We'll make sure the 'bold' font weight is more than the
# default font weight without specifying a specific value
baseline_font_weight = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
)
# Third line should be bold
third_line = rows.locator("div").nth(2)
first_char = third_line.locator("span").nth(0)
font_weight = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
)
assert int(font_weight) > int(baseline_font_weight)
# Fourth line should be italic
fourth_line = rows.locator("div").nth(3)
first_char = fourth_line.locator("span").nth(0)
font_style = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-style')"
)
assert font_style == "italic"
def test_xterm_multiple(self):
"""Test whether multiple x-terms on the page all function"""
self.pyscript_run(
"""
<py-config>
xterm = true
</py-config>
<py-script>
print("\x1b[33mYellow\x1b[0m")
print("done")
</py-script>
<py-terminal id="a"></py-terminal>
<py-terminal id="b" data-testid="b"></py-terminal>
"""
)
# Wait for "done" to actually appear in the xterm; may be delayed,
# since xtermjs processes its input buffer in chunks
last_line = self.page.get_by_test_id("b").get_by_text("done")
last_line.wait_for()
# Yes, this is not ideal. See note in `test_xterm_function`
time.sleep(1)
rows = self.page.locator("#a .xterm-rows")
# First line should be yellow
first_line = rows.locator("div").nth(0)
first_char = first_line.locator("span").nth(0)
color = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('color')"
)
assert color == "rgb(196, 160, 0)"

View File

@@ -211,6 +211,35 @@ class TestDocsSnippets(PyScriptTest):
assert "0\n1\n2\n" in py_terminal.inner_text()
@skip_worker("FIXME: js.document")
def test_reference_pyterminal_xterm(self):
self.pyscript_run(
"""
<py-config>
xterm = true
</py-config>
<py-script>
print("HELLO!")
import js
import asyncio
async def adjust_term_size(columns, rows):
xterm = await js.document.querySelector('py-terminal').xtermReady
xterm.resize(columns, rows)
print("test-done")
asyncio.ensure_future(adjust_term_size(40, 10))
</py-script>
"""
)
self.page.get_by_text("test-done").wait_for()
py_terminal = self.page.locator("py-terminal")
print(dir(py_terminal))
print(type(py_terminal))
assert py_terminal.evaluate("el => el.xterm.cols") == 40
assert py_terminal.evaluate("el => el.xterm.rows") == 10
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_reference_when_simple(self):
self.pyscript_run(