Skip to content

Commit 8e24e69

Browse files
authored
Merge pull request #303 from x1unix/feat/xterm
Use XTerm for program output
2 parents 19345e6 + 20040b0 commit 8e24e69

39 files changed

+1132
-325
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
[![Docker Hub](https://img.shields.io/docker/pulls/x1unix/go-playground.svg)](https://hub.docker.com/r/x1unix/go-playground)
44
[![Docker Hub](https://img.shields.io/docker/v/x1unix/go-playground.svg?sort=semver)](https://hub.docker.com/r/x1unix/go-playground)
5+
[![Better Stack Badge](https://uptime.betterstack.com/status-badges/v1/monitor/100nk.svg)](https://uptime.betterstack.com/?utm_source=status_badge)
56
[![Coverage Status](https://coveralls.io/repos/github/x1unix/go-playground/badge.svg?branch=dev)](https://coveralls.io/github/x1unix/go-playground?branch=dev)
67
[![Goreportcard](https://goreportcard.com/badge/github.com/x1unix/go-playground)](https://goreportcard.com/report/github.com/x1unix/go-playground)
78
[![StandWithUkraine](https://rg.gosu.cc/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)
@@ -15,6 +16,7 @@ Improved Go Playground powered by Monaco Editor and React - [https://goplay.tool
1516
* 🌚 Dark theme
1617
* 💡 Code autocomplete
1718
* ⌨️ VIM mode support
19+
* 🌈 Color and image output
1820
* 💾 Load and save files
1921
* 📔 Snippets and tutorials
2022
* ⚙ Customization (fonts, ligatures, etc)

web/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"@types/jest": "^27.4.0",
1212
"@types/react": "^17.0.39",
1313
"@types/react-dom": "^17.0.11",
14+
"@xterm/addon-canvas": "^0.6.0-beta.1",
15+
"@xterm/addon-fit": "^0.9.0-beta.1",
16+
"@xterm/addon-image": "^0.7.0-beta.1",
17+
"@xterm/addon-webgl": "^0.17.0-beta.1",
18+
"@xterm/xterm": "^5.4.0-beta.1",
1419
"axios": "^1.6.0",
1520
"circular-dependency-plugin": "^5.2.2",
1621
"clsx": "^1.1.1",

web/src/components/core/Header/Header.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import {
44
ICommandBarItemProps, Stack,
55
} from '@fluentui/react';
66

7-
import apiClient, { VersionsInfo } from "~/services/api";
7+
import apiClient, { type VersionsInfo } from "~/services/api";
88
import { newAddNotificationAction, NotificationType } from "~/store/notifications";
9-
import SettingsModal, { SettingsChanges } from '~/components/settings/SettingsModal';
9+
import SettingsModal, { type SettingsChanges } from '~/components/settings/SettingsModal';
1010
import ThemeableComponent from '~/components/utils/ThemeableComponent';
1111
import AboutModal from '~/components/modals/AboutModal';
1212
import RunTargetSelector from '~/components/inputs/RunTargetSelector';
1313
import SharePopup from '~/components/utils/SharePopup';
14+
15+
import { dispatchTerminalSettingsChange } from '~/store/terminal';
1416
import {
1517
Connect,
16-
Dispatcher,
18+
type Dispatcher,
1719
dispatchToggleTheme,
1820
formatFileDispatcher,
1921
newCodeImportDispatcher,
@@ -25,6 +27,7 @@ import {
2527
saveFileDispatcher,
2628
shareSnippetDispatcher
2729
} from '~/store';
30+
2831
import { getSnippetsMenuItems, SnippetMenuItem } from './utils';
2932

3033
import './Header.css';
@@ -242,6 +245,10 @@ export class Header extends ThemeableComponent<any, HeaderState> {
242245
this.props.dispatch(newSettingsChangeDispatcher(changes.settings));
243246
}
244247

248+
if (changes.terminal) {
249+
this.props.dispatch(dispatchTerminalSettingsChange(changes.terminal));
250+
}
251+
245252
this.setState({ showSettings: false });
246253
}
247254

web/src/components/core/Panel/PanelHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, {useContext} from 'react';
22
import {ITheme, ThemeContext} from '@fluentui/react';
3-
import PanelAction, {PanelActionProps} from '@components/core/Panel/PanelAction';
3+
import PanelAction, {PanelActionProps} from '~/components/core/Panel/PanelAction';
44
import './PanelHeader.css';
55

66
interface Props {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
.app-Console {
2+
flex: 1 1 auto;
3+
position: relative;
4+
box-sizing: border-box;
5+
6+
--terminal-padding-x: 15px;
7+
}
8+
9+
.app-Console__xterm {
10+
position: absolute;
11+
inset: 0;
12+
}
13+
14+
.app-Console .terminal > * {
15+
padding: 0 var(--terminal-padding-x);
16+
}
17+
18+
/**
19+
xterm.js canvas plugin fixes.
20+
21+
The plugin places a few canvas elements inside with absolute positioning
22+
relative to parent and doesn't respect parent's paddings.
23+
*/
24+
.app-Console .xterm .xterm-screen canvas {
25+
transform: translate3d(var(--terminal-padding-x), 0, 0);
26+
}
27+
28+
/**
29+
Copy button for touch devices
30+
*/
31+
.app-Console__copy {
32+
position: absolute;
33+
display: none;
34+
z-index: 999;
35+
top: 0;
36+
right: var(--terminal-padding-x);
37+
}
38+
39+
.app-Console__copy[hidden] {
40+
display: none;
41+
}
42+
43+
/* Enable copy button only on touch devices */
44+
@media (hover: none) {
45+
.app-Console__copy {
46+
display: block;
47+
}
48+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2+
import copy from 'copy-to-clipboard';
3+
4+
import {DefaultButton, useTheme} from '@fluentui/react';
5+
6+
import type {ITerminalAddon, ITerminalOptions} from '@xterm/xterm';
7+
import {FitAddon} from '@xterm/addon-fit';
8+
import {ImageAddon} from '@xterm/addon-image';
9+
import {CanvasAddon} from '@xterm/addon-canvas';
10+
import {WebglAddon} from '@xterm/addon-webgl';
11+
12+
import type {StatusState} from '~/store';
13+
import {RenderingBackend} from '~/store/terminal';
14+
import {useXtermTheme, XTerm} from '~/components/utils/XTerm';
15+
16+
import {formatEvalEvent} from './format';
17+
import {createDebounceResizeObserver} from './utils';
18+
19+
import './Console.css';
20+
21+
const RESIZE_DELAY = 50;
22+
23+
const imageAddonConfig = {
24+
enableSizeReports: true,
25+
sixelSupport: true,
26+
sixelScrolling: true,
27+
iipSupport: true,
28+
};
29+
30+
const config: ITerminalOptions = {
31+
convertEol: true,
32+
};
33+
34+
interface Props {
35+
status?: StatusState
36+
fontFamily: string
37+
fontSize: number
38+
backend: RenderingBackend
39+
}
40+
41+
const getAddonFromBackend = (backend: RenderingBackend): ITerminalAddon | null => {
42+
switch (backend) {
43+
case RenderingBackend.WebGL:
44+
return new WebglAddon();
45+
case RenderingBackend.Canvas:
46+
return new CanvasAddon();
47+
default:
48+
return null;
49+
}
50+
}
51+
52+
const CopyButton: React.FC<{
53+
onClick?: () => void
54+
hidden?: boolean
55+
}> = ({onClick, hidden}) => {
56+
const theme = useTheme();
57+
const styles = useMemo(() => ({
58+
root: {
59+
color: theme?.palette.neutralPrimary,
60+
marginLeft: 'auto',
61+
marginTop: '4px',
62+
marginRight: '2px',
63+
padding: '4px 8px',
64+
minWidth: 'initial'
65+
},
66+
rootHovered: {
67+
color: theme?.palette.neutralDark
68+
}
69+
}), [theme]);
70+
return (
71+
<DefaultButton
72+
className='app-Console__copy'
73+
iconProps={{iconName: 'Copy'}}
74+
ariaLabel='Copy'
75+
onClick={onClick}
76+
styles={styles}
77+
hidden={hidden}
78+
/>
79+
);
80+
}
81+
82+
/**
83+
* Console is Go program events output component based on xterm.js
84+
*/
85+
export const Console: React.FC<Props> = ({fontFamily, fontSize, status, backend}) => {
86+
const theme = useXtermTheme();
87+
const [offset, setOffset] = useState(0);
88+
const [isFocused, setIsFocused] = useState(false);
89+
90+
const xtermRef = useRef<XTerm>(null);
91+
const fitAddonRef = useRef(new FitAddon());
92+
const imageAddonRef = useRef(new ImageAddon(imageAddonConfig));
93+
94+
const resizeObserver = useMemo(() => (
95+
createDebounceResizeObserver(() => {
96+
fitAddonRef.current.fit();
97+
}, RESIZE_DELAY)
98+
), [fitAddonRef]);
99+
100+
const isClean = !status?.dirty;
101+
const events = status?.events;
102+
const terminal = xtermRef.current?.terminal;
103+
const elemRef = xtermRef?.current?.terminalRef;
104+
105+
const copySelection = useCallback(() => {
106+
if (!terminal) {
107+
return;
108+
}
109+
110+
const shouldTrim = !terminal.hasSelection();
111+
if (!terminal.hasSelection()) {
112+
terminal.selectAll();
113+
}
114+
115+
const str = terminal.getSelection();
116+
terminal.clearSelection();
117+
118+
// TODO: notify about copy result
119+
copy(shouldTrim ? str.trim() : str);
120+
}, [terminal]);
121+
122+
// Track output events
123+
useEffect(() => {
124+
if (!events?.length || !terminal) {
125+
setOffset(0);
126+
terminal?.clear();
127+
terminal?.reset();
128+
return;
129+
}
130+
131+
if (offset === 0) {
132+
terminal?.clear();
133+
terminal?.reset();
134+
}
135+
136+
const batch = events?.slice(offset);
137+
if (!batch) {
138+
return;
139+
}
140+
141+
batch.map(formatEvalEvent).forEach((msg) => terminal?.write(msg));
142+
terminal?.scrollToBottom();
143+
setOffset(offset + batch.length);
144+
}, [terminal, offset, events ])
145+
146+
// Reset output offset on clean
147+
useEffect(() => {
148+
if (isClean) {
149+
setOffset(0)
150+
}
151+
152+
}, [isClean])
153+
154+
// Track terminal resize
155+
useEffect(() => {
156+
if (!elemRef?.current) {
157+
resizeObserver.disconnect();
158+
return;
159+
}
160+
161+
resizeObserver.observe(elemRef.current);
162+
return () => {
163+
resizeObserver.disconnect();
164+
}
165+
}, [elemRef, resizeObserver]);
166+
167+
// Theme
168+
useEffect(() => {
169+
if (!terminal) {
170+
return;
171+
}
172+
173+
terminal.options = {
174+
theme,
175+
fontSize,
176+
fontFamily,
177+
};
178+
}, [theme, terminal, fontFamily, fontSize]);
179+
180+
// Rendering backend
181+
useEffect(() => {
182+
if (!terminal) {
183+
return;
184+
}
185+
186+
console.log('xterm: switched backend:', backend);
187+
const addon = getAddonFromBackend(backend);
188+
if (!addon) {
189+
return;
190+
}
191+
192+
terminal.loadAddon(addon);
193+
return () => {
194+
console.log('xterm: unloading old backend:', backend);
195+
addon.dispose();
196+
};
197+
}, [terminal, backend]);
198+
199+
// Register button on focus
200+
useEffect(() => {
201+
if (!terminal?.textarea) {
202+
return;
203+
}
204+
205+
terminal.textarea.addEventListener('focus', () => {
206+
setIsFocused(true);
207+
});
208+
209+
terminal.textarea.addEventListener('blur', () => {
210+
// Delay before blur to keep enough time for btn click
211+
setTimeout(() => setIsFocused(false), 150);
212+
});
213+
214+
return () => setIsFocused(false);
215+
}, [terminal?.textarea, setIsFocused]);
216+
217+
return (
218+
<div className="app-Console">
219+
<CopyButton
220+
hidden={!isFocused}
221+
onClick={copySelection}
222+
/>
223+
<XTerm
224+
ref={xtermRef}
225+
className='app-Console__xterm'
226+
addons={[
227+
fitAddonRef.current,
228+
imageAddonRef.current,
229+
]}
230+
options={{
231+
...config,
232+
theme,
233+
fontSize,
234+
fontFamily,
235+
}}
236+
/>
237+
</div>
238+
);
239+
}

0 commit comments

Comments
 (0)