Skip to content

Commit 932756c

Browse files
authored
Add Option to make Py-Terminal and Xterm.js (pyscript#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
1 parent 538aac9 commit 932756c

File tree

7 files changed

+360
-34
lines changed

7 files changed

+360
-34
lines changed

docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Features
88
--------
99

10+
- Added the `xterm` attribute to `py-config`. When set to `True` or `xterm`, an (output-only) [xterm.js](http://xtermjs.org/) terminal will be used in place of the default py-terminal.
1011
- The default version of Pyodide is now `0.23.2`. See the [Pyodide Changelog](https://pyodide.org/en/stable/project/changelog.html#version-0-23-2) for a detailed list of changes.
1112
- Added the `@when` decorator for attaching Python functions as event handlers
1213
- The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release.

docs/reference/plugins/py-terminal.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This is one of the core plugins in PyScript, which is active by default. With it
44

55
## Configuration
66

7-
You can control how `<py-terminal>` behaves by setting the value of the `terminal` configuration in your `<py-config>`, together with the `docked` one.
7+
You can control how `<py-terminal>` behaves by setting the values of the `terminal`, `docked`, and `xterm` fields in your configuration in your `<py-config>`.
88

99
For the **terminal** field, these are the values:
1010

@@ -26,6 +26,31 @@ Please note that **docked** mode is currently used as default only when `termina
2626

2727
In all other cases it's up to the user decide if a terminal should be docked or not.
2828

29+
For the **xterm** field, these are the values:
30+
31+
| value | description |
32+
|-------|-------------|
33+
| `false` | This is the default. The `<py-terminal>` is a simple `<pre>` tag with some CSS styling. |
34+
| `true` or `xterm` | The [xtermjs](http://xtermjs.org/) library is loaded and its Terminal object is used as the `<py-terminal>`. It's visibility and position are determined by the `docked` and `auto` keys in the same way as the default `<py-terminal>` |
35+
36+
The xterm.js [Terminal object](http://xtermjs.org/docs/api/terminal/classes/terminal/) can be accessed directly if you want to adjust its properties, add [custom parser hooks](http://xtermjs.org/docs/guides/hooks/), introduce [xterm.js addons](http://xtermjs.org/docs/guides/using-addons/), etc. Access is best achieved by awaiting the `xtermReady` attribute of the `<py-terminal>` HTML element itself:
37+
38+
```python
39+
import js
40+
import asyncio
41+
42+
async def adjust_term_size(columns, rows):
43+
xterm = await js.document.querySelector('py-terminal').xtermReady
44+
xterm.resize(columns, rows)
45+
46+
asyncio.ensure_future(adjust_term_size(40,10))
47+
```
48+
49+
Some terminal-formatting packages read from specific environment variables to determine whether they should emit formatted output; PyScript does not set these variables explicitly - you may need to set them yourself, or force your terminal-formatting package into a state where it outputs correctly formatted output.
50+
51+
A couple of specific examples:
52+
- the [rich](https://github.com/Textualize/rich) will not, by default, output colorful text, but passing `256` or `truecolor` as an argument as the `color_system` parameter to the [Console constructor](https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console) will force it to do so. (As of rich v13)
53+
- [termcolor](https://github.com/termcolor/termcolor) will not, by default, output colorful text, but setting `os.environ["FORCE_COLOR"] = "True"` or by passing `force_color=True` to the `colored()` function will force it to do so. (As of termcolor v2.3)
2954

3055
### Examples
3156

pyscriptjs/package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyscriptjs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"pyodide": "0.23.2",
3838
"synclink": "0.2.4",
3939
"ts-jest": "29.0.3",
40-
"typescript": "5.0.4"
40+
"typescript": "5.0.4",
41+
"xterm": "^5.1.0"
4142
},
4243
"dependencies": {
4344
"basic-devtools": "^0.1.6",

pyscriptjs/src/plugins/pyterminal.ts

Lines changed: 170 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import { Plugin, validateConfigParameterFromArray } from '../plugin';
66
import { getLogger } from '../logger';
77
import { type Stdio } from '../stdio';
88
import { InterpreterClient } from '../interpreter_client';
9+
import { Terminal as TerminalType } from 'xterm';
910

10-
const logger = getLogger('py-terminal');
11+
const knownPyTerminalTags: WeakSet<HTMLElement> = new WeakSet();
1112

1213
type AppConfigStyle = AppConfig & {
13-
terminal?: string | boolean;
14-
docked?: string | boolean;
14+
terminal?: boolean | 'auto';
15+
docked?: boolean | 'docked';
16+
xterm?: boolean | 'xterm';
1517
};
1618

19+
const logger = getLogger('py-terminal');
20+
1721
export class PyTerminalPlugin extends Plugin {
1822
app: PyScriptApp;
1923

@@ -36,19 +40,27 @@ export class PyTerminalPlugin extends Plugin {
3640
possibleValues: [true, false, 'docked'],
3741
defaultValue: 'docked',
3842
});
43+
validateConfigParameterFromArray({
44+
config: config,
45+
name: 'xterm',
46+
possibleValues: [true, false, 'xterm'],
47+
defaultValue: false,
48+
});
3949
}
4050

4151
beforeLaunch(config: AppConfigStyle) {
4252
// if config.terminal is "yes" or "auto", let's add a <py-terminal> to
4353
// the document, unless it's already present.
44-
const { terminal: t, docked: d } = config;
54+
const { terminal: t, docked: d, xterm: x } = config;
4555
const auto = t === true || t === 'auto';
4656
const docked = d === true || d === 'docked';
57+
const xterm = x === true || x === 'xterm';
4758
if (auto && $('py-terminal', document) === null) {
4859
logger.info('No <py-terminal> found, adding one');
4960
const termElem = document.createElement('py-terminal');
5061
if (auto) termElem.setAttribute('auto', '');
5162
if (docked) termElem.setAttribute('docked', '');
63+
if (xterm) termElem.setAttribute('xterm', '');
5264
document.body.appendChild(termElem);
5365
}
5466
}
@@ -57,7 +69,8 @@ export class PyTerminalPlugin extends Plugin {
5769
// the Python interpreter has been initialized and we are ready to
5870
// execute user code:
5971
//
60-
// 1. define the "py-terminal" custom element
72+
// 1. define the "py-terminal" custom element, either a <pre> element
73+
// or using xterm.js
6174
//
6275
// 2. if there is a <py-terminal> tag on the page, it will register
6376
// a Stdio listener just before the user code executes, ensuring
@@ -70,59 +83,185 @@ export class PyTerminalPlugin extends Plugin {
7083
//
7184
// 4. (in the future we might want to add an option to start the
7285
// capture earlier, but I don't think it's important now).
73-
const PyTerminal = make_PyTerminal(this.app);
86+
const PyTerminal = _interpreter.config.xterm ? make_PyTerminal_xterm(this.app) : make_PyTerminal_pre(this.app);
7487
customElements.define('py-terminal', PyTerminal);
7588
}
7689
}
7790

78-
function make_PyTerminal(app: PyScriptApp) {
91+
abstract class PyTerminalBaseClass extends HTMLElement implements Stdio {
92+
autoShowOnNextLine: boolean;
93+
94+
isAuto() {
95+
return this.hasAttribute('auto');
96+
}
97+
98+
isDocked() {
99+
return this.hasAttribute('docked');
100+
}
101+
102+
setupPosition(app: PyScriptApp) {
103+
if (this.isAuto()) {
104+
this.classList.add('py-terminal-hidden');
105+
this.autoShowOnNextLine = true;
106+
} else {
107+
this.autoShowOnNextLine = false;
108+
}
109+
110+
if (this.isDocked()) {
111+
this.classList.add('py-terminal-docked');
112+
}
113+
114+
logger.info('Registering stdio listener');
115+
app.registerStdioListener(this);
116+
}
117+
118+
abstract stdout_writeline(msg: string): void;
119+
abstract stderr_writeline(msg: string): void;
120+
}
121+
122+
function make_PyTerminal_pre(app: PyScriptApp) {
79123
/** The <py-terminal> custom element, which automatically register a stdio
80124
* listener to capture and display stdout/stderr
81125
*/
82-
class PyTerminal extends HTMLElement implements Stdio {
126+
class PyTerminalPre extends PyTerminalBaseClass {
83127
outElem: HTMLElement;
84-
autoShowOnNextLine: boolean;
85128

86129
connectedCallback() {
87130
// should we use a shadowRoot instead? It looks unnecessarily
88131
// complicated to me, but I'm not really sure about the
89132
// implications
90133
this.outElem = document.createElement('pre');
91-
this.outElem.className = 'py-terminal';
134+
this.outElem.classList.add('py-terminal');
92135
this.appendChild(this.outElem);
93136

94-
if (this.isAuto()) {
95-
this.classList.add('py-terminal-hidden');
96-
this.autoShowOnNextLine = true;
97-
} else {
98-
this.autoShowOnNextLine = false;
99-
}
137+
this.setupPosition(app);
138+
}
100139

140+
// implementation of the Stdio interface
141+
stdout_writeline(msg: string) {
142+
this.outElem.innerText += msg + '\n';
101143
if (this.isDocked()) {
102-
this.classList.add('py-terminal-docked');
144+
this.scrollTop = this.scrollHeight;
145+
}
146+
if (this.autoShowOnNextLine) {
147+
this.classList.remove('py-terminal-hidden');
148+
this.autoShowOnNextLine = false;
103149
}
150+
}
151+
152+
stderr_writeline(msg: string) {
153+
this.stdout_writeline(msg);
154+
}
155+
// end of the Stdio interface
156+
}
104157

105-
logger.info('Registering stdio listener');
106-
app.registerStdioListener(this);
158+
return PyTerminalPre;
159+
}
160+
161+
declare const Terminal: typeof TerminalType;
162+
163+
function make_PyTerminal_xterm(app: PyScriptApp) {
164+
/** The <py-terminal> custom element, which automatically register a stdio
165+
* listener to capture and display stdout/stderr
166+
*/
167+
class PyTerminalXterm extends PyTerminalBaseClass {
168+
outElem: HTMLDivElement;
169+
_moduleResolved: boolean;
170+
xtermReady: Promise<TerminalType>;
171+
xterm: TerminalType;
172+
cachedStdOut: Array<string>;
173+
cachedStdErr: Array<string>;
174+
_xterm_cdn_base_url = 'https://cdn.jsdelivr.net/npm/xterm@5.1.0';
175+
176+
constructor() {
177+
super();
178+
this.cachedStdOut = [];
179+
this.cachedStdErr = [];
180+
181+
// While this is false, store writes to stdout/stderr to a buffer
182+
// when the xterm.js is actually ready, we will "replay" those writes
183+
// and set this to true
184+
this._moduleResolved = false;
185+
186+
//Required to make xterm appear properly
187+
this.style.width = '100%';
188+
this.style.height = '100%';
107189
}
108190

109-
isAuto() {
110-
return this.hasAttribute('auto');
191+
async connectedCallback() {
192+
//guard against initializing a tag twice
193+
if (knownPyTerminalTags.has(this)) return;
194+
knownPyTerminalTags.add(this);
195+
196+
this.outElem = document.createElement('div');
197+
//this.outElem.className = 'py-terminal';
198+
this.appendChild(this.outElem);
199+
200+
this.setupPosition(app);
201+
202+
this.xtermReady = this._setupXterm();
203+
await this.xtermReady;
111204
}
112205

113-
isDocked() {
114-
return this.hasAttribute('docked');
206+
/**
207+
* Fetch the xtermjs library from CDN an initialize it.
208+
* @private
209+
* @returns the associated xterm.js Terminal
210+
*/
211+
async _setupXterm() {
212+
if (this.xterm == undefined) {
213+
//need to initialize the Terminal for this element
214+
215+
// eslint-disable-next-line
216+
// @ts-ignore
217+
if (globalThis.Terminal == undefined) {
218+
//load xterm module from cdn
219+
//eslint-disable-next-line
220+
//@ts-ignore
221+
await import(this._xterm_cdn_base_url + '/lib/xterm.js');
222+
223+
const cssTag = document.createElement('link');
224+
cssTag.type = 'text/css';
225+
cssTag.rel = 'stylesheet';
226+
cssTag.href = this._xterm_cdn_base_url + '/css/xterm.css';
227+
document.head.appendChild(cssTag);
228+
}
229+
230+
//Create xterm, add addons
231+
this.xterm = new Terminal({ screenReaderMode: true, cols: 80 });
232+
233+
// xterm must only 'open' into a visible DOM element
234+
// If terminal is still hidden, open during first write
235+
if (!this.autoShowOnNextLine) this.xterm.open(this);
236+
237+
this._moduleResolved = true;
238+
239+
//Write out any messages output while xterm was loading
240+
this.cachedStdOut.forEach((value: string): void => this.stdout_writeline(value));
241+
this.cachedStdErr.forEach((value: string): void => this.stderr_writeline(value));
242+
} else {
243+
this._moduleResolved = true;
244+
}
245+
return this.xterm;
115246
}
116247

117248
// implementation of the Stdio interface
118249
stdout_writeline(msg: string) {
119-
this.outElem.innerText += msg + '\n';
120-
if (this.isDocked()) {
121-
this.scrollTop = this.scrollHeight;
122-
}
123-
if (this.autoShowOnNextLine) {
124-
this.classList.remove('py-terminal-hidden');
125-
this.autoShowOnNextLine = false;
250+
if (this._moduleResolved) {
251+
this.xterm.writeln(msg);
252+
//this.outElem.innerText += msg + '\n';
253+
254+
if (this.isDocked()) {
255+
this.scrollTop = this.scrollHeight;
256+
}
257+
if (this.autoShowOnNextLine) {
258+
this.classList.remove('py-terminal-hidden');
259+
this.autoShowOnNextLine = false;
260+
this.xterm.open(this);
261+
}
262+
} else {
263+
//if xtermjs not loaded, cache messages
264+
this.cachedStdOut.push(msg);
126265
}
127266
}
128267

@@ -132,5 +271,5 @@ function make_PyTerminal(app: PyScriptApp) {
132271
// end of the Stdio interface
133272
}
134273

135-
return PyTerminal;
274+
return PyTerminalXterm;
136275
}

0 commit comments

Comments
 (0)