Add some missing functionality to terminal emulator and fix some things.

Also make it a little more readable by splitting actual commands out into separate methods.
This commit is contained in:
Florian Nücke
2022-01-07 07:55:53 +01:00
parent ed81efb4d6
commit 54ab9418e3

View File

@@ -23,7 +23,7 @@ import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.atomic.AtomicInteger;
// Implements a couple of control sequences from here: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences
// VT100 emulation: https://vt100.net/docs/vt100-ug/chapter3.html
@Serialized
public final class Terminal {
public static final int WIDTH = 80, HEIGHT = 24;
@@ -32,14 +32,31 @@ public final class Terminal {
private static final int TAB_WIDTH = 4;
private static final int COLOR_BLACK = 0;
private static final int COLOR_RED = 1;
private static final int COLOR_GREEN = 2;
private static final int COLOR_YELLOW = 3;
private static final int COLOR_BLUE = 4;
private static final int COLOR_MAGENTA = 5;
private static final int COLOR_CYAN = 6;
private static final int COLOR_WHITE = 7;
@SuppressWarnings("unused")
private static final class Color {
static final int BLACK = 0;
static final int RED = 1;
static final int GREEN = 2;
static final int YELLOW = 3;
static final int BLUE = 4;
static final int MAGENTA = 5;
static final int CYAN = 6;
static final int WHITE = 7;
}
@SuppressWarnings("unused")
private static final class Mode {
static final int LNM = 20; // Line Feed/New Line Mode
static final int DECCKM = 1; // Cursor key
static final int DECANM = 2; // ANSI/VT52
static final int DECCOLM = 3; // Column
static final int DECSCLM = 4; // Scrolling
static final int DECSCNM = 5; // Screen
static final int DECOM = 6; // Origin
static final int DECAWM = 7; // Auto wrap
static final int DECARM = 8; // Auto repeating
static final int DECINLM = 9; // Interlace
}
private static final int COLOR_MASK = 0b111;
private static final int COLOR_FOREGROUND_SHIFT = 3;
@@ -52,7 +69,7 @@ public final class Terminal {
private static final int STYLE_HIDDEN_MASK = 1 << 5;
// Default style: no modifiers, white foreground, black background.
private static final byte DEFAULT_COLORS = COLOR_WHITE << COLOR_FOREGROUND_SHIFT;
private static final byte DEFAULT_COLORS = Color.WHITE << COLOR_FOREGROUND_SHIFT;
private static final byte DEFAULT_STYLE = 0;
///////////////////////////////////////////////////////////////////
@@ -60,7 +77,10 @@ public final class Terminal {
public enum State { // Must be public for serialization.
NORMAL, // Currently reading characters normally.
ESCAPE, // Last character was ESC, figure out what kind next.
SEQUENCE, // Know what sequence we have, now parsing it.
SHIFT_IN_CHARACTER_SET, // Shift in character set.
SHIFT_OUT_CHARACTER_SET, // Shift out character set.
HASH, // Escape sequence with # intermediate.
CONTROL_SEQUENCE, // Know what sequence we have, now parsing it.
}
public interface RendererView {
@@ -73,18 +93,21 @@ public final class Terminal {
private final byte[] buffer = new byte[WIDTH * HEIGHT];
private final byte[] colors = new byte[WIDTH * HEIGHT];
private final byte[] styles = new byte[WIDTH * HEIGHT];
private final boolean[] tabs = new boolean[WIDTH];
private State state = State.NORMAL;
private final int[] args = new int[4];
private int argCount = 0;
private int modes;
private int scrollFirst = 0, scrollLast = HEIGHT - 1;
private int x, y;
private int savedX, savedY;
// Color info packed into one byte for compact storage
// 0-2: background color (index)
// 3-5: foreground color (index)
private byte color = DEFAULT_COLORS;
private byte color;
// Style info packed into one byte for compact storage
private byte style = DEFAULT_STYLE;
private byte style;
// Rendering data for client
private final transient Set<RendererModel> renderers = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
@@ -94,7 +117,7 @@ public final class Terminal {
///////////////////////////////////////////////////////////////////
public Terminal() {
clear();
RIS();
}
///////////////////////////////////////////////////////////////////
@@ -179,36 +202,56 @@ public final class Terminal {
switch (state) {
case NORMAL -> {
switch (value) {
case (byte) '\r' -> setCursorPos(0, y);
case (byte) '\n' -> putNewLine();
case (byte) '\t' -> {
if (x + TAB_WIDTH > WIDTH) {
setCursorPos(0, y);
putNewLine();
case '\007' -> hasPendingBell = true;
case '\033' -> state = State.ESCAPE;
case '\016' -> { } // SO
case '\017' -> { } // SI
case (byte) '\r' /* 015 */ -> setCursorPos(0, y);
case (byte) '\n' /* 012 */, '\013', '\014' -> {
if (getMode(Mode.LNM)) {
NEL();
} else {
setCursorPos(x + TAB_WIDTH - (x % TAB_WIDTH), y);
IND();
}
}
case (byte) '\b' -> setCursorPos(x - 1, y);
case 7 -> hasPendingBell = true;
case 27 -> state = State.ESCAPE;
default -> {
if (!Character.isISOControl(ch)) {
putChar(ch);
case (byte) '\t' /* 011 */ -> {
while (x < WIDTH && !tabs[x]) {
x++;
}
}
case (byte) '\b' /* 010 */ -> setCursorPos(Math.min(x, WIDTH - 1) - 1, y);
default -> putChar(ch);
}
}
case ESCAPE -> {
if (ch == '[') {
if (ch == '[') { // Control Sequence Indicator
Arrays.fill(args, (byte) 0);
argCount = 0;
state = State.SEQUENCE;
state = State.CONTROL_SEQUENCE;
} else if (ch == '(') { // SCS Select Character Set
state = State.SHIFT_IN_CHARACTER_SET;
} else if (ch == ')') { // SCS Select Character Set
state = State.SHIFT_OUT_CHARACTER_SET;
} else if (ch == '#') { // # Intermediate
state = State.HASH;
} else {
state = State.NORMAL;
switch (ch) {
case 'D' -> IND(); // IND Index
case 'E' -> NEL(); // NEL Next Line
case 'M' -> RI(); // RI Reverse Index
case '7' -> DECSC(); // DECSC Save Cursor (DEC Private)
case '8' -> DECRC(); // DECRC Restore Cursor (DEC Private)
case 'H' -> HTS(); // HTS Horizontal Tabulation Set
case 'c' -> RIS(); // RIS Reset To Initial State
case '=' -> { } // DECKPAM Keypad Application Mode (DEC Private)
case '>' -> { } // DECKPNM Keypad Numeric Mode (DEC Private)
}
}
}
case SEQUENCE -> {
case CONTROL_SEQUENCE -> {
if (ch >= '0' && ch <= '9') {
if (argCount < args.length) {
final int digit = ch - '0';
@@ -219,101 +262,272 @@ public final class Terminal {
}
}
} else {
if (ch == '?') {
break; // Ignore ? intermediate character.
}
if (argCount < args.length) {
argCount++;
}
if (ch == ';' || ch == '?') {
break;
if (ch == ';') {
break; // Keep going, we have another argument.
}
state = State.NORMAL;
switch (ch) {
case 'A' -> // Cursor Up
setCursorPos(x, y - Math.max(1, args[0]));
case 'B' -> // Cursor Down
setCursorPos(x, y + Math.max(1, args[0]));
case 'C' -> // Cursor Forward
setCursorPos(x + Math.max(1, args[0]), y);
case 'D' -> // Cursor Back
setCursorPos(x - Math.max(1, args[0]), y);
case 'E' -> // Cursor Next Line
setCursorPos(0, y + Math.min(1, args[0]));
case 'F' -> // Cursor Previous Line
setCursorPos(0, y - Math.min(1, args[0]));
case 'G' -> // Cursor Horizontal Absolute
setCursorPos(args[0] - 1, y);
// Don't care about terminal mode fanciness so just alias.
case 'f', 'H' -> // Cursor Position
setCursorPos(args[1] - 1, args[0] - 1);
case 'J' -> { // Erase in Display
if (args[0] == 0) { // Cursor and down
clearLine(y, x, WIDTH);
for (int iy = y + 1; iy < HEIGHT; iy++) {
clearLine(iy);
}
} else if (args[0] == 1) { // Cursor and up
clearLine(y, 0, x + 1);
for (int iy = 0; iy < y; iy++) {
clearLine(iy);
}
} else if (args[0] == 2) { // Everything
clear();
}
}
case 'K' -> { // Erase in Line
if (args[0] == 0) { // Cursor and right
clearLine(y, x, WIDTH);
} else if (args[0] == 1) { // Cursor and left
clearLine(y, 0, x + 1);
} else if (args[0] == 2) { // ...entirely
clearLine(y);
}
}
// S, T: Scroll Up/Down. We don't have scrollback.
case 'm' -> { // Select Graphic Rendition
for (int i = 0; i < argCount; i++) {
final int arg = args[i];
selectStyle(arg);
}
}
case 'n' -> { // Device Status Report
switch (args[0]) {
case 5 -> { // Report console status
if (!displayOnly) {
putInput((byte) 27);
for (final char i : "[0n".toCharArray()) {
putInput((byte) i);
}
}
}
case 6 -> { // Report cursor position
if (!displayOnly) {
putInput((byte) 27);
for (final char i : String.format("[%d;%dR", (y % HEIGHT) + 1, x + 1).toCharArray()) {
putInput((byte) i);
}
}
}
}
}
case 's' -> { // Save Current Cursor Position
savedX = x;
savedY = y;
}
case 'u' -> { // Restore Saved Cursor Position
x = savedX;
y = savedY;
}
case 'A' -> CUU(); // CUU - Cursor Up
case 'B' -> CUD(); // CUD Cursor Down
case 'C' -> CUF(); // CUF Cursor Forward
case 'D' -> CUB(); // CUB Cursor Backward
case 'H' -> CUP(); // CUP - Cursor Position
case 'f' -> HVP(); // HVP Horizontal and Vertical Position
case 'm' -> SGR(); // SGR Select Graphic Rendition
case 'K' -> EL(); // EL Erase In Line
case 'J' -> ED(); // ED Erase In Display
case 'r' -> DECSTBM(); // DECSTBM Set Top and Bottom Margins (DEC Private)
case 'g' -> TBC(); // TBC Tabulation Clear
case 'h' -> SM(); // SM Set Mode
case 'l' -> RM(); // RM Reset Mode
case 'n' -> DSR(); // DSR Device Status Report
case 'c' -> DA(); // DA Device Attributes
}
}
}
case SHIFT_IN_CHARACTER_SET, SHIFT_OUT_CHARACTER_SET -> {
state = State.NORMAL;
switch (ch) {
case 'A' -> { } // United Kingdom Set
case 'B' -> { } // ASCII Set
case '0' -> { } // Special Graphics
case '1' -> { } // Alternate Character ROM Standard Character Set
case '2' -> { } // Alternate Character ROM Special Graphics
}
}
case HASH -> {
state = State.NORMAL;
switch (ch) {
case '3' -> { } // Change this line to double-height top half (DECDHL)
case '4' -> { } // Change this line to double-height bottom half (DECDHL)
case '5' -> { } // Change this line to single-width single-height (DECSWL)
case '6' -> { } // Change this line to double-width single-height (DECDWL)
case '8' -> { // Fill Screen with Es (DECALN)
Arrays.fill(buffer, (byte) 'E');
renderers.forEach(model -> model.getDirtyMask().set(-1));
}
}
}
}
}
///////////////////////////////////////////////////////////////////
private void IND() {
if (y >= scrollLast) {
shiftUpOne();
} else {
setCursorPos(x, y + 1);
}
}
private void NEL() {
if (y >= scrollLast) {
shiftUpOne();
setCursorPos(0, y);
} else {
setCursorPos(0, y + 1);
}
}
private void RI() {
if (y <= scrollFirst) {
shiftDownOne();
} else {
setCursorPos(0, y - 1);
}
}
private void DECSC() {
savedX = x;
savedY = y;
}
private void DECRC() {
x = savedX;
y = savedY;
}
private void HTS() {
if (x >= 0 && x < WIDTH) {
tabs[x] = true;
}
}
private void RIS() {
color = DEFAULT_COLORS;
style = DEFAULT_STYLE;
clear();
Arrays.fill(tabs, false);
for (int i = 0; i < WIDTH; i++) {
if (i % TAB_WIDTH == 0) {
tabs[i] = true;
}
}
}
private void CUU() {
setClampedCursorPos(x, y - Math.max(1, args[0]));
}
private void CUD() {
setClampedCursorPos(x, y + Math.max(1, args[0]));
}
private void CUF() {
setClampedCursorPos(x + Math.max(1, args[0]), y);
}
private void CUB() {
setClampedCursorPos(x - Math.max(1, args[0]), y);
}
private void CUP() {
setRelativeCursorPos(args[1] - 1, args[0] - 1);
}
private void HVP() {
CUP();
}
private void SGR() {
for (int i = 0; i < argCount; i++) {
selectStyle(args[i]);
}
}
private void EL() {
switch (args[0]) {
case 0 -> // From cursor to end of line
clearLine(y, x, WIDTH);
case 1 -> // From beginning of line to cursor
clearLine(y, 0, x + 1);
case 2 -> // Entire line containing cursor
clearLine(y);
}
}
private void ED() {
switch (args[0]) {
case 0 -> { // From cursor to end of screen
clearLine(y, x, WIDTH);
for (int iy = y + 1; iy < HEIGHT; iy++) {
clearLine(iy);
}
}
case 1 -> { // From beginning of screen to cursor
for (int iy = 0; iy < y; iy++) {
clearLine(iy);
}
clearLine(y, 0, x + 1);
}
case 2 -> // Entire screen
clear();
}
}
private void DECSTBM() {
final int first, last;
if (argCount == 2) {
first = args[0] - 1;
last = args[1] - 1;
} else {
first = 0;
last = HEIGHT - 1;
}
if (first < 0 || last > HEIGHT - 1 || last - first <= 0) {
return;
}
scrollFirst = first; // to index
scrollLast = last; // to index
setRelativeCursorPos(0, 0); // send cursor home
}
private void TBC() {
switch (args[0]) {
case 0 -> { // Clear tab at current column
if (x >= 0 && x < WIDTH) {
tabs[x] = false;
}
}
case 3 -> // Clear all tabs
Arrays.fill(tabs, false);
}
}
private void SM() {
for (int i = 0; i < argCount; i++) {
final int mode = args[i];
if (mode != 0) {
setMode(mode);
}
if (mode == Mode.DECOM) {
setRelativeCursorPos(0, 0);
}
}
}
private void RM() {
for (int i = 0; i < argCount; i++) {
final int mode = args[i];
if (mode != 0) {
resetMode(mode);
}
if (mode == Mode.DECOM) {
setRelativeCursorPos(0, 0);
clear();
}
}
}
private void DSR() {
switch (args[0]) {
case 5 -> // Report console status
putResponse("\033[0n"); // Ready, No malfunctions detected
case 6 -> { // Report cursor position
if (getMode(Mode.DECOM)) {
putResponse(String.format("\033[%d;%dR", (y - scrollFirst) + 1, x + 1));
} else {
putResponse(String.format("\033[%d;%dR", (y % HEIGHT) + 1, x + 1));
}
}
}
}
private void DA() {
putResponse("\033[?1;0c"); // No options.
}
private void setMode(final int mode) {
modes |= 1 << mode;
}
private void resetMode(final int mode) {
modes &= ~(1 << mode);
}
private boolean getMode(final int mode) {
return (modes & (1 << mode)) != 0;
}
private void putResponse(final String value) {
for (int i = 0; i < value.length(); i++) {
putResponse((byte) value.charAt(i));
}
}
private void putResponse(final byte value) {
if (!displayOnly) {
putInput(value);
}
}
private void selectStyle(final int sgr) {
switch (sgr) {
@@ -325,11 +539,11 @@ public final class Terminal {
style |= STYLE_BOLD_MASK;
case 2 -> // Faint or decreased intensity
style |= STYLE_DIM_MASK;
case 4 -> // Underline
case 4 -> // Underscore
style |= STYLE_UNDERLINE_MASK;
case 5 -> // Slow Blink
case 5 -> // Blink
style |= STYLE_BLINK_MASK;
case 7 -> // Reverse video
case 7 -> // Negative (reverse) image
style |= STYLE_INVERT_MASK;
case 8 -> // Conceal aka Hide
style |= STYLE_HIDDEN_MASK;
@@ -354,15 +568,33 @@ public final class Terminal {
}
}
private void setRelativeCursorPos(final int x, final int y) {
if (getMode(Mode.DECOM)) {
setCursorPos(x, Math.min(scrollFirst + y, scrollLast));
} else {
setCursorPos(x, y);
}
}
private void setClampedCursorPos(final int x, final int y) {
setCursorPos(x, Math.max(scrollFirst, Math.min(scrollLast, y)));
}
private void setCursorPos(final int x, final int y) {
this.x = Math.max(0, Math.min(WIDTH - 1, x));
this.y = Math.max(0, Math.min(HEIGHT - 1, y));
}
private void putChar(final char ch) {
if (Character.isISOControl(ch))
return;
if (x >= WIDTH) {
setCursorPos(0, y);
putNewLine();
if (getMode(Mode.DECAWM)) {
NEL();
} else {
setCursorPos(WIDTH - 1, y);
}
}
setChar(x, y, ch);
@@ -387,7 +619,7 @@ public final class Terminal {
Arrays.fill(buffer, (byte) ' ');
Arrays.fill(colors, DEFAULT_COLORS);
Arrays.fill(styles, DEFAULT_STYLE);
renderers.forEach(model -> model.getDirtyMask().set((1 << HEIGHT) - 1));
renderers.forEach(model -> model.getDirtyMask().set(-1));
}
private void clearLine(final int y) {
@@ -401,24 +633,41 @@ public final class Terminal {
renderers.forEach(model -> model.getDirtyMask().accumulateAndGet(1 << y, (prev, next) -> prev | next));
}
private void putNewLine() {
y++;
if (y >= HEIGHT) {
y = HEIGHT - 1;
shiftUpOne();
}
private void shiftUpOne() {
shiftLines(scrollFirst + 1, scrollLast, -1);
}
private void shiftUpOne() {
System.arraycopy(buffer, WIDTH, buffer, 0, buffer.length - WIDTH);
System.arraycopy(colors, WIDTH, colors, 0, colors.length - WIDTH);
System.arraycopy(styles, WIDTH, styles, 0, styles.length - WIDTH);
Arrays.fill(buffer, WIDTH * HEIGHT - WIDTH, WIDTH * HEIGHT, (byte) ' ');
Arrays.fill(colors, WIDTH * HEIGHT - WIDTH, WIDTH * HEIGHT, DEFAULT_COLORS);
Arrays.fill(styles, WIDTH * HEIGHT - WIDTH, WIDTH * HEIGHT, DEFAULT_STYLE);
private void shiftDownOne() {
shiftLines(scrollFirst, scrollLast - 1, 1);
}
// Offset is baked into buffers so we must rebuild them all.
renderers.forEach(model -> model.getDirtyMask().set(-1));
private void shiftLines(final int firstLine, final int lastLine, final int count) {
if (count == 0)
return;
final int srcIndex = firstLine * WIDTH;
final int charCount = (lastLine + 1) * WIDTH - srcIndex;
final int dstIndex = srcIndex + count * WIDTH;
System.arraycopy(buffer, srcIndex, buffer, dstIndex, charCount);
System.arraycopy(colors, srcIndex, colors, dstIndex, charCount);
System.arraycopy(styles, srcIndex, styles, dstIndex, charCount);
final int clearIndex = count > 0 ? srcIndex : (dstIndex + charCount);
final int clearCount = Math.abs(count * WIDTH);
Arrays.fill(buffer, clearIndex, clearIndex + clearCount, (byte) ' ');
// TODO Copy color and style from last line.
Arrays.fill(colors, clearIndex, clearIndex + clearCount, DEFAULT_COLORS);
Arrays.fill(styles, clearIndex, clearIndex + clearCount, DEFAULT_STYLE);
int dirtyLinesMask = 0;
final int dirtyStart = Math.min(firstLine, firstLine + count);
final int dirtyEnd = Math.max(lastLine, lastLine + count);
for (int i = dirtyStart; i <= dirtyEnd; i++) {
dirtyLinesMask |= 1 << i;
}
final int finalDirtyLinesMask = dirtyLinesMask;
renderers.forEach(model -> model.getDirtyMask().accumulateAndGet(finalDirtyLinesMask, (left, right) -> left | right));
}
///////////////////////////////////////////////////////////////////
@@ -671,7 +920,7 @@ public final class Terminal {
final BufferBuilder buffer = Tesselator.getInstance().getBuilder();
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
final int foreground = COLORS[COLOR_WHITE];
final int foreground = COLORS[Color.WHITE];
final float r = ((foreground >> 16) & 0xFF) / 255f;
final float g = ((foreground >> 8) & 0xFF) / 255f;
final float b = ((foreground) & 0xFF) / 255f;