From 54ab9418e3b4f17e650cfada012a925aa2eda0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20N=C3=BCcke?= Date: Fri, 7 Jan 2022 07:55:53 +0100 Subject: [PATCH] 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. --- .../java/li/cil/oc2/common/vm/Terminal.java | 515 +++++++++++++----- 1 file changed, 382 insertions(+), 133 deletions(-) diff --git a/src/main/java/li/cil/oc2/common/vm/Terminal.java b/src/main/java/li/cil/oc2/common/vm/Terminal.java index f14d93af..188d9d4a 100644 --- a/src/main/java/li/cil/oc2/common/vm/Terminal.java +++ b/src/main/java/li/cil/oc2/common/vm/Terminal.java @@ -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 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;