mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
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:
15
pyscriptjs/package-lock.json
generated
15
pyscriptjs/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user