Skip to content

Use XTerm for program output #303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
198f59e
chore: add xterm
x1unix Jan 13, 2024
137972a
fix: refactor preview content
x1unix Jan 14, 2024
14ce209
feat: add xterm component
x1unix Jan 14, 2024
d008351
feat: render using xterm
x1unix Jan 14, 2024
21c2783
feat: fix render
x1unix Jan 14, 2024
9d7b4ad
feat: paint stderr
x1unix Jan 14, 2024
c8695b6
feat: add addon fit
x1unix Jan 14, 2024
a91f0ab
feat: add autoresize
x1unix Jan 14, 2024
fc07d5c
feat: fix padding
x1unix Jan 14, 2024
124990e
fix: scroll to bottom on print
x1unix Jan 14, 2024
06d2d73
fix: fix overflow
x1unix Jan 14, 2024
8b5bd1e
feat: support theming
x1unix Jan 14, 2024
c083ffe
feat: simplify imports
x1unix Jan 14, 2024
00ff849
feat: convert playground image outputs
x1unix Jan 14, 2024
1c17a1b
fix: adjust theme and image format
x1unix Jan 15, 2024
ada1bd9
fix: adjust colors
x1unix Jan 15, 2024
5557903
feat: apply font config
x1unix Jan 15, 2024
e69e3f3
fix: fix iip image formatting
x1unix Jan 16, 2024
ce45d0e
fix: simplify html
x1unix Jan 16, 2024
fcd790a
feat: extract console into separate component
x1unix Jan 16, 2024
d60c14c
fix: reorganize preview components
x1unix Jan 16, 2024
1c63d68
fix: reorganize preview components
x1unix Jan 16, 2024
e445f18
fix: preserve inspector components state
x1unix Jan 16, 2024
ef924b4
fix: fix terminal overflow
x1unix Jan 16, 2024
45e0364
fix: add xterm canvas fix
x1unix Jan 16, 2024
9710da7
fix: fix overflow
x1unix Jan 16, 2024
f226538
fix: remove comments
x1unix Jan 16, 2024
753f95f
chore: add uptime badge
x1unix Jan 16, 2024
77d7db1
fix: properly clear terminal
x1unix Jan 17, 2024
373e599
fix: fix writer for wasm
x1unix Jan 17, 2024
8fc08a7
fix: remove redundant log
x1unix Jan 17, 2024
be3483d
feat: add terminal settings to store
x1unix Jan 17, 2024
d5b36c0
feat: add terminal config to store
x1unix Jan 17, 2024
1c90013
feat: add copy button for mobile
x1unix Jan 17, 2024
dddd526
feat: propagate font changes
x1unix Jan 17, 2024
d26e7f4
feat: remove terminal emulation switch
x1unix Jan 17, 2024
6e97ed0
feat: add xterm backend switch
x1unix Jan 17, 2024
20040b0
chore: update readme
x1unix Jan 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![Docker Hub](https://img.shields.io/docker/pulls/x1unix/go-playground.svg)](https://hub.docker.com/r/x1unix/go-playground)
[![Docker Hub](https://img.shields.io/docker/v/x1unix/go-playground.svg?sort=semver)](https://hub.docker.com/r/x1unix/go-playground)
[![Better Stack Badge](https://uptime.betterstack.com/status-badges/v1/monitor/100nk.svg)](https://uptime.betterstack.com/?utm_source=status_badge)
[![Coverage Status](https://coveralls.io/repos/github/x1unix/go-playground/badge.svg?branch=dev)](https://coveralls.io/github/x1unix/go-playground?branch=dev)
[![Goreportcard](https://goreportcard.com/badge/github.com/x1unix/go-playground)](https://goreportcard.com/report/github.com/x1unix/go-playground)
[![StandWithUkraine](https://rg.gosu.cc/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)
Expand All @@ -15,6 +16,7 @@ Improved Go Playground powered by Monaco Editor and React - [https://goplay.tool
* 🌚 Dark theme
* 💡 Code autocomplete
* ⌨️ VIM mode support
* 🌈 Color and image output
* 💾 Load and save files
* 📔 Snippets and tutorials
* ⚙ Customization (fonts, ligatures, etc)
Expand Down
5 changes: 5 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"@types/jest": "^27.4.0",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@xterm/addon-canvas": "^0.6.0-beta.1",
"@xterm/addon-fit": "^0.9.0-beta.1",
"@xterm/addon-image": "^0.7.0-beta.1",
"@xterm/addon-webgl": "^0.17.0-beta.1",
"@xterm/xterm": "^5.4.0-beta.1",
"axios": "^1.6.0",
"circular-dependency-plugin": "^5.2.2",
"clsx": "^1.1.1",
Expand Down
13 changes: 10 additions & 3 deletions web/src/components/core/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import {
ICommandBarItemProps, Stack,
} from '@fluentui/react';

import apiClient, { VersionsInfo } from "~/services/api";
import apiClient, { type VersionsInfo } from "~/services/api";
import { newAddNotificationAction, NotificationType } from "~/store/notifications";
import SettingsModal, { SettingsChanges } from '~/components/settings/SettingsModal';
import SettingsModal, { type SettingsChanges } from '~/components/settings/SettingsModal';
import ThemeableComponent from '~/components/utils/ThemeableComponent';
import AboutModal from '~/components/modals/AboutModal';
import RunTargetSelector from '~/components/inputs/RunTargetSelector';
import SharePopup from '~/components/utils/SharePopup';

import { dispatchTerminalSettingsChange } from '~/store/terminal';
import {
Connect,
Dispatcher,
type Dispatcher,
dispatchToggleTheme,
formatFileDispatcher,
newCodeImportDispatcher,
Expand All @@ -25,6 +27,7 @@ import {
saveFileDispatcher,
shareSnippetDispatcher
} from '~/store';

import { getSnippetsMenuItems, SnippetMenuItem } from './utils';

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

if (changes.terminal) {
this.props.dispatch(dispatchTerminalSettingsChange(changes.terminal));
}

this.setState({ showSettings: false });
}

Expand Down
2 changes: 1 addition & 1 deletion web/src/components/core/Panel/PanelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, {useContext} from 'react';
import {ITheme, ThemeContext} from '@fluentui/react';
import PanelAction, {PanelActionProps} from '@components/core/Panel/PanelAction';
import PanelAction, {PanelActionProps} from '~/components/core/Panel/PanelAction';
import './PanelHeader.css';

interface Props {
Expand Down
48 changes: 48 additions & 0 deletions web/src/components/inspector/Console/Console.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.app-Console {
flex: 1 1 auto;
position: relative;
box-sizing: border-box;

--terminal-padding-x: 15px;
}

.app-Console__xterm {
position: absolute;
inset: 0;
}

.app-Console .terminal > * {
padding: 0 var(--terminal-padding-x);
}

/**
xterm.js canvas plugin fixes.

The plugin places a few canvas elements inside with absolute positioning
relative to parent and doesn't respect parent's paddings.
*/
.app-Console .xterm .xterm-screen canvas {
transform: translate3d(var(--terminal-padding-x), 0, 0);
}

/**
Copy button for touch devices
*/
.app-Console__copy {
position: absolute;
display: none;
z-index: 999;
top: 0;
right: var(--terminal-padding-x);
}

.app-Console__copy[hidden] {
display: none;
}

/* Enable copy button only on touch devices */
@media (hover: none) {
.app-Console__copy {
display: block;
}
}
239 changes: 239 additions & 0 deletions web/src/components/inspector/Console/Console.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import copy from 'copy-to-clipboard';

import {DefaultButton, useTheme} from '@fluentui/react';

import type {ITerminalAddon, ITerminalOptions} from '@xterm/xterm';
import {FitAddon} from '@xterm/addon-fit';
import {ImageAddon} from '@xterm/addon-image';
import {CanvasAddon} from '@xterm/addon-canvas';
import {WebglAddon} from '@xterm/addon-webgl';

import type {StatusState} from '~/store';
import {RenderingBackend} from '~/store/terminal';
import {useXtermTheme, XTerm} from '~/components/utils/XTerm';

import {formatEvalEvent} from './format';
import {createDebounceResizeObserver} from './utils';

import './Console.css';

const RESIZE_DELAY = 50;

const imageAddonConfig = {
enableSizeReports: true,
sixelSupport: true,
sixelScrolling: true,
iipSupport: true,
};

const config: ITerminalOptions = {
convertEol: true,
};

interface Props {
status?: StatusState
fontFamily: string
fontSize: number
backend: RenderingBackend
}

const getAddonFromBackend = (backend: RenderingBackend): ITerminalAddon | null => {
switch (backend) {
case RenderingBackend.WebGL:
return new WebglAddon();
case RenderingBackend.Canvas:
return new CanvasAddon();
default:
return null;
}
}

const CopyButton: React.FC<{
onClick?: () => void
hidden?: boolean
}> = ({onClick, hidden}) => {
const theme = useTheme();
const styles = useMemo(() => ({
root: {
color: theme?.palette.neutralPrimary,
marginLeft: 'auto',
marginTop: '4px',
marginRight: '2px',
padding: '4px 8px',
minWidth: 'initial'
},
rootHovered: {
color: theme?.palette.neutralDark
}
}), [theme]);
return (
<DefaultButton
className='app-Console__copy'
iconProps={{iconName: 'Copy'}}
ariaLabel='Copy'
onClick={onClick}
styles={styles}
hidden={hidden}
/>
);
}

/**
* Console is Go program events output component based on xterm.js
*/
export const Console: React.FC<Props> = ({fontFamily, fontSize, status, backend}) => {
const theme = useXtermTheme();
const [offset, setOffset] = useState(0);
const [isFocused, setIsFocused] = useState(false);

const xtermRef = useRef<XTerm>(null);
const fitAddonRef = useRef(new FitAddon());
const imageAddonRef = useRef(new ImageAddon(imageAddonConfig));

const resizeObserver = useMemo(() => (
createDebounceResizeObserver(() => {
fitAddonRef.current.fit();
}, RESIZE_DELAY)
), [fitAddonRef]);

const isClean = !status?.dirty;
const events = status?.events;
const terminal = xtermRef.current?.terminal;
const elemRef = xtermRef?.current?.terminalRef;

const copySelection = useCallback(() => {
if (!terminal) {
return;
}

const shouldTrim = !terminal.hasSelection();
if (!terminal.hasSelection()) {
terminal.selectAll();
}

const str = terminal.getSelection();
terminal.clearSelection();

// TODO: notify about copy result
copy(shouldTrim ? str.trim() : str);
}, [terminal]);

// Track output events
useEffect(() => {
if (!events?.length || !terminal) {
setOffset(0);
terminal?.clear();
terminal?.reset();
return;
}

if (offset === 0) {
terminal?.clear();
terminal?.reset();
}

const batch = events?.slice(offset);
if (!batch) {
return;
}

batch.map(formatEvalEvent).forEach((msg) => terminal?.write(msg));
terminal?.scrollToBottom();
setOffset(offset + batch.length);
}, [terminal, offset, events ])

// Reset output offset on clean
useEffect(() => {
if (isClean) {
setOffset(0)
}

}, [isClean])

// Track terminal resize
useEffect(() => {
if (!elemRef?.current) {
resizeObserver.disconnect();
return;
}

resizeObserver.observe(elemRef.current);
return () => {
resizeObserver.disconnect();
}
}, [elemRef, resizeObserver]);

// Theme
useEffect(() => {
if (!terminal) {
return;
}

terminal.options = {
theme,
fontSize,
fontFamily,
};
}, [theme, terminal, fontFamily, fontSize]);

// Rendering backend
useEffect(() => {
if (!terminal) {
return;
}

console.log('xterm: switched backend:', backend);
const addon = getAddonFromBackend(backend);
if (!addon) {
return;
}

terminal.loadAddon(addon);
return () => {
console.log('xterm: unloading old backend:', backend);
addon.dispose();
};
}, [terminal, backend]);

// Register button on focus
useEffect(() => {
if (!terminal?.textarea) {
return;
}

terminal.textarea.addEventListener('focus', () => {
setIsFocused(true);
});

terminal.textarea.addEventListener('blur', () => {
// Delay before blur to keep enough time for btn click
setTimeout(() => setIsFocused(false), 150);
});

return () => setIsFocused(false);
}, [terminal?.textarea, setIsFocused]);

return (
<div className="app-Console">
<CopyButton
hidden={!isFocused}
onClick={copySelection}
/>
<XTerm
ref={xtermRef}
className='app-Console__xterm'
addons={[
fitAddonRef.current,
imageAddonRef.current,
]}
options={{
...config,
theme,
fontSize,
fontFamily,
}}
/>
</div>
);
}
Loading