diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index ac29e37..1e493d1 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -19,12 +19,7 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; -import android.text.SpannableStringBuilder; -import android.text.Spanned; import android.text.InputType; -import android.text.style.BackgroundColorSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.View; @@ -62,7 +57,6 @@ public final class MainActivity extends Activity { private static final int MAX_TERMINAL_COLS = 140; private static final int MIN_TERMINAL_ROWS = 8; private static final int MAX_TERMINAL_ROWS = 80; - private static final int MAX_TERMINAL_CHARS = 40_000; private static final int STATUS_NORMAL = 0; private static final int STATUS_BUSY = 1; private static final int STATUS_SUCCESS = 2; @@ -102,7 +96,7 @@ public final class MainActivity extends Activity { private String activeSessionName; private String activeMainPage = PAGE_SESSIONS; private String pendingImageUploadSession; - private final StringBuilder terminalBuffer = new StringBuilder(); + private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private final StringBuilder queuedTerminalInput = new StringBuilder(); private boolean terminalConnected; private boolean terminalRenderPending; @@ -971,7 +965,7 @@ public final class MainActivity extends Activity { private void openTerminal(String sessionName) { activeSessionName = sessionName; - terminalBuffer.setLength(0); + terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); queuedTerminalInput.setLength(0); terminalConnected = false; terminalRenderPending = false; @@ -1132,7 +1126,7 @@ public final class MainActivity extends Activity { .setItems(items, (dialog, which) -> { switch (which) { case 0: - terminalBuffer.setLength(0); + terminalScreen.clear(); terminalText.setText(""); if (terminalSocket != null) { terminalSocket.clearHistory(); @@ -1829,13 +1823,15 @@ public final class MainActivity extends Activity { if (lineHeight <= 0) { lineHeight = dp(16); } - int cols = clamp((int) Math.floor((width - horizontalPadding) / charWidth), MIN_TERMINAL_COLS, MAX_TERMINAL_COLS); + int cols = clamp((int) Math.floor((width - horizontalPadding) / charWidth) - 1, MIN_TERMINAL_COLS, MAX_TERMINAL_COLS); int rows = clamp((height - verticalPadding) / lineHeight, MIN_TERMINAL_ROWS, MAX_TERMINAL_ROWS); if (cols == terminalCols && rows == terminalRows && !forceSend) { return; } terminalCols = cols; terminalRows = rows; + terminalScreen.resize(cols, rows); + scheduleTerminalRender(); TerminalSocketClient socket = terminalSocket; if (socket != null && !socket.isClosed() && terminalConnected) { socket.resize(cols, rows); @@ -1911,10 +1907,7 @@ public final class MainActivity extends Activity { } private void appendTerminal(String data) { - terminalBuffer.append(data); - if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { - terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); - } + terminalScreen.write(data); scheduleTerminalRender(); } @@ -1936,194 +1929,10 @@ public final class MainActivity extends Activity { return; } lastTerminalRenderMs = System.currentTimeMillis(); - terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString())); + terminalText.setText(terminalScreen.render()); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); } - private CharSequence renderAnsiForTerminal(String text) { - SpannableStringBuilder output = new SpannableStringBuilder(); - int fg = Color.rgb(230, 235, 242); - int bg = Color.TRANSPARENT; - boolean bold = false; - int index = 0; - while (index < text.length()) { - char item = text.charAt(index); - if (item == '\r') { - deleteCurrentTerminalLine(output); - index++; - continue; - } - if (item == '\b') { - if (output.length() > 0 && output.charAt(output.length() - 1) != '\n') { - output.delete(output.length() - 1, output.length()); - } - index++; - continue; - } - if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') { - int end = findAnsiEnd(text, index + 2); - if (end == -1) { - break; - } - char command = text.charAt(end); - if (command == 'm') { - int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold); - fg = state[0]; - bg = state[1]; - bold = state[2] == 1; - } else if (command == 'K') { - String params = text.substring(index + 2, end); - if (params.startsWith("2")) { - deleteCurrentTerminalLine(output); - } - } else if (command == 'J') { - String params = text.substring(index + 2, end); - if (params.startsWith("2") || params.startsWith("3")) { - output.clear(); - } - } - index = end + 1; - continue; - } - if (item == '\u001b') { - int skipped = skipNonCsiEscape(text, index); - if (skipped > index) { - index = skipped; - continue; - } - } - - int runStart = index; - while (index < text.length()) { - char runItem = text.charAt(index); - if (runItem == '\r' || runItem == '\b' || runItem == '\u001b') { - break; - } - index++; - } - appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold); - } - return output; - } - - private void deleteCurrentTerminalLine(SpannableStringBuilder output) { - int start = output.length(); - while (start > 0 && output.charAt(start - 1) != '\n') { - start--; - } - if (start < output.length()) { - output.delete(start, output.length()); - } - } - - private int skipNonCsiEscape(String text, int index) { - if (index + 1 >= text.length()) { - return index + 1; - } - char next = text.charAt(index + 1); - if (next == ']' || next == 'P' || next == '^' || next == '_') { - int cursor = index + 2; - while (cursor < text.length()) { - char item = text.charAt(cursor); - if (item == '\u0007') { - return cursor + 1; - } - if (item == '\u001b' && cursor + 1 < text.length() && text.charAt(cursor + 1) == '\\') { - return cursor + 2; - } - cursor++; - } - return text.length(); - } - if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') { - return Math.min(index + 3, text.length()); - } - return Math.min(index + 2, text.length()); - } - - private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) { - if (text.isEmpty()) { - return; - } - int start = output.length(); - output.append(text); - int finish = output.length(); - output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - if (bg != Color.TRANSPARENT) { - output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - if (bold) { - output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - - private int findAnsiEnd(String text, int start) { - for (int index = start; index < text.length(); index++) { - char item = text.charAt(index); - if (item >= '@' && item <= '~') { - return index; - } - } - return -1; - } - - private int[] applySgr(String params, int fg, int bg, boolean bold) { - if (params.isEmpty()) { - params = "0"; - } - String[] parts = params.split(";"); - for (String part : parts) { - int value; - try { - value = part.isEmpty() ? 0 : Integer.parseInt(part); - } catch (NumberFormatException ignored) { - continue; - } - if (value == 0) { - fg = Color.rgb(230, 235, 242); - bg = Color.TRANSPARENT; - bold = false; - } else if (value == 1) { - bold = true; - } else if (value == 22) { - bold = false; - } else if (value == 39) { - fg = Color.rgb(230, 235, 242); - } else if (value == 49) { - bg = Color.TRANSPARENT; - } else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) { - fg = ansiColor(value, false); - } else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) { - bg = ansiColor(value, true); - } - } - return new int[]{fg, bg, bold ? 1 : 0}; - } - - private int ansiColor(int code, boolean background) { - int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30); - boolean bright = code >= 90; - switch (base) { - case 0: - return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45); - case 1: - return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73); - case 2: - return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80); - case 3: - return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34); - case 4: - return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255); - case 5: - return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255); - case 6: - return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187); - case 7: - default: - return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217); - } - } - private void saveServerAndRefresh() { String url = normalizeServerUrl(urlField.getText().toString()); prefs.edit().putString("server_url", url).apply(); diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java new file mode 100644 index 0000000..becc134 --- /dev/null +++ b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java @@ -0,0 +1,615 @@ +package com.neatstudio.tmuxandroid; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; + +import java.util.ArrayList; +import java.util.List; + +final class TerminalScreenBuffer { + private static final int DEFAULT_FG = 0xffe6ebf2; + private static final int DEFAULT_BG = Color.TRANSPARENT; + + private int cols; + private int rows; + private Cell[][] cells; + private int cursorRow; + private int cursorCol; + private int savedRow; + private int savedCol; + private String pendingControl = ""; + private boolean wrapPending; + private int fg = DEFAULT_FG; + private int bg = DEFAULT_BG; + private boolean bold; + + TerminalScreenBuffer(int cols, int rows) { + resize(cols, rows); + } + + void resize(int nextCols, int nextRows) { + nextCols = Math.max(1, nextCols); + nextRows = Math.max(1, nextRows); + Cell[][] previous = cells; + int previousRows = rows; + int previousCols = cols; + cols = nextCols; + rows = nextRows; + cells = new Cell[rows][cols]; + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + cells[row][col] = new Cell(); + } + } + if (previous != null) { + int copyRows = Math.min(previousRows, rows); + int copyCols = Math.min(previousCols, cols); + int previousStart = Math.max(0, previousRows - copyRows); + int nextStart = Math.max(0, rows - copyRows); + for (int row = 0; row < copyRows; row++) { + for (int col = 0; col < copyCols; col++) { + cells[nextStart + row][col].copyFrom(previous[previousStart + row][col]); + } + } + } + cursorRow = clamp(cursorRow, 0, rows - 1); + cursorCol = clamp(cursorCol, 0, cols - 1); + savedRow = clamp(savedRow, 0, rows - 1); + savedCol = clamp(savedCol, 0, cols - 1); + } + + void clear() { + clearScreen(); + cursorRow = 0; + cursorCol = 0; + savedRow = 0; + savedCol = 0; + pendingControl = ""; + wrapPending = false; + fg = DEFAULT_FG; + bg = DEFAULT_BG; + bold = false; + } + + void write(String text) { + if (!pendingControl.isEmpty()) { + text = pendingControl + text; + pendingControl = ""; + } + int index = 0; + while (index < text.length()) { + char item = text.charAt(index); + if (item == '\u001b') { + int next = handleEscape(text, index); + if (next < 0) { + pendingControl = text.substring(index); + return; + } + index = next; + } else if (item == '\r') { + wrapPending = false; + cursorCol = 0; + index++; + } else if (item == '\n') { + wrapPending = false; + newLine(); + index++; + } else if (item == '\b') { + wrapPending = false; + cursorCol = Math.max(0, cursorCol - 1); + index++; + } else if (item == '\t') { + int nextTab = ((cursorCol / 8) + 1) * 8; + while (cursorCol < Math.min(nextTab, cols)) { + putChar(' '); + } + index++; + } else if (item >= 0x20 && item != 0x7f) { + putChar(item); + index++; + } else { + index++; + } + } + } + + CharSequence render() { + SpannableStringBuilder output = new SpannableStringBuilder(); + for (int row = 0; row < rows; row++) { + appendRow(output, row); + if (row + 1 < rows) { + output.append('\n'); + } + } + return output; + } + + private int handleEscape(String text, int index) { + if (index + 1 >= text.length()) { + return -1; + } + char next = text.charAt(index + 1); + wrapPending = false; + if (next == '[') { + int end = findAnsiEnd(text, index + 2); + if (end == -1) { + return -1; + } + applyCsi(text.substring(index + 2, end), text.charAt(end)); + return end + 1; + } + if (next == ']') { + return skipStringEscape(text, index + 2); + } + if (next == 'P' || next == '^' || next == '_') { + return skipStringEscape(text, index + 2); + } + if (next == '(' || next == ')' || next == '*' || next == '+' || next == '-' || next == '.') { + if (index + 2 >= text.length()) { + return -1; + } + return Math.min(index + 3, text.length()); + } + if (next == '7') { + saveCursor(); + } else if (next == '8') { + restoreCursor(); + } else if (next == 'D') { + newLine(); + } else if (next == 'E') { + cursorCol = 0; + newLine(); + } else if (next == 'M') { + reverseIndex(); + } else if (next == 'c') { + clear(); + } + return Math.min(index + 2, text.length()); + } + + private void applyCsi(String rawParams, char command) { + wrapPending = false; + String params = rawParams; + while (!params.isEmpty()) { + char first = params.charAt(0); + if (first == '?' || first == '>' || first == '!' || first == '=') { + params = params.substring(1); + } else { + break; + } + } + List values = parseParams(params); + switch (command) { + case 'm': + applySgr(values); + break; + case 'H': + case 'f': + cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1); + cursorCol = clamp(param(values, 1, 1) - 1, 0, cols - 1); + break; + case 'A': + cursorRow = clamp(cursorRow - param(values, 0, 1), 0, rows - 1); + break; + case 'B': + cursorRow = clamp(cursorRow + param(values, 0, 1), 0, rows - 1); + break; + case 'C': + cursorCol = clamp(cursorCol + param(values, 0, 1), 0, cols - 1); + break; + case 'D': + cursorCol = clamp(cursorCol - param(values, 0, 1), 0, cols - 1); + break; + case 'G': + cursorCol = clamp(param(values, 0, 1) - 1, 0, cols - 1); + break; + case 'd': + cursorRow = clamp(param(values, 0, 1) - 1, 0, rows - 1); + break; + case 'J': + eraseDisplay(param(values, 0, 0)); + break; + case 'K': + eraseLine(param(values, 0, 0)); + break; + case 'P': + deleteChars(param(values, 0, 1)); + break; + case '@': + insertChars(param(values, 0, 1)); + break; + case 'X': + eraseChars(param(values, 0, 1)); + break; + case 'L': + insertLines(param(values, 0, 1)); + break; + case 'M': + deleteLines(param(values, 0, 1)); + break; + case 's': + saveCursor(); + break; + case 'u': + restoreCursor(); + break; + case 'c': + break; + default: + break; + } + } + + private void applySgr(List values) { + if (values.isEmpty()) { + values.add(0); + } + for (int index = 0; index < values.size(); index++) { + int value = values.get(index); + if (value == 0) { + fg = DEFAULT_FG; + bg = DEFAULT_BG; + bold = false; + } else if (value == 1) { + bold = true; + } else if (value == 22) { + bold = false; + } else if (value == 39) { + fg = DEFAULT_FG; + } else if (value == 49) { + bg = DEFAULT_BG; + } else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) { + fg = ansiColor(value, false); + } else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) { + bg = ansiColor(value, true); + } else if ((value == 38 || value == 48) && index + 2 < values.size()) { + boolean background = value == 48; + int mode = values.get(index + 1); + if (mode == 5) { + int color = xtermColor(values.get(index + 2)); + if (background) { + bg = color; + } else { + fg = color; + } + index += 2; + } else if (mode == 2 && index + 4 < values.size()) { + int color = Color.rgb( + clamp(values.get(index + 2), 0, 255), + clamp(values.get(index + 3), 0, 255), + clamp(values.get(index + 4), 0, 255) + ); + if (background) { + bg = color; + } else { + fg = color; + } + index += 4; + } + } + } + } + + private void putChar(char value) { + if (wrapPending) { + wrapPending = false; + newLine(); + } + cells[cursorRow][cursorCol].set(value, fg, bg, bold); + if (cursorCol == cols - 1) { + wrapPending = true; + } else { + cursorCol++; + } + } + + private void newLine() { + wrapPending = false; + cursorRow++; + if (cursorRow >= rows) { + scrollUp(1); + cursorRow = rows - 1; + } + cursorCol = 0; + } + + private void reverseIndex() { + if (cursorRow == 0) { + scrollDown(1); + } else { + cursorRow--; + } + } + + private void eraseDisplay(int mode) { + if (mode == 2 || mode == 3) { + clearScreen(); + } else if (mode == 1) { + for (int row = 0; row < cursorRow; row++) { + clearLine(row, 0, cols - 1); + } + clearLine(cursorRow, 0, cursorCol); + } else { + clearLine(cursorRow, cursorCol, cols - 1); + for (int row = cursorRow + 1; row < rows; row++) { + clearLine(row, 0, cols - 1); + } + } + } + + private void eraseLine(int mode) { + if (mode == 2) { + clearLine(cursorRow, 0, cols - 1); + } else if (mode == 1) { + clearLine(cursorRow, 0, cursorCol); + } else { + clearLine(cursorRow, cursorCol, cols - 1); + } + } + + private void eraseChars(int count) { + int end = Math.min(cols - 1, cursorCol + Math.max(1, count) - 1); + clearLine(cursorRow, cursorCol, end); + } + + private void deleteChars(int count) { + count = Math.max(1, count); + Cell[] line = cells[cursorRow]; + for (int col = cursorCol; col < cols; col++) { + int source = col + count; + if (source < cols) { + line[col].copyFrom(line[source]); + } else { + line[col].clear(); + } + } + } + + private void insertChars(int count) { + count = Math.max(1, count); + Cell[] line = cells[cursorRow]; + for (int col = cols - 1; col >= cursorCol; col--) { + int source = col - count; + if (source >= cursorCol) { + line[col].copyFrom(line[source]); + } else { + line[col].clear(); + } + } + } + + private void insertLines(int count) { + count = Math.min(Math.max(1, count), rows - cursorRow); + for (int row = rows - 1; row >= cursorRow + count; row--) { + copyLine(row, row - count); + } + for (int row = cursorRow; row < cursorRow + count; row++) { + clearLine(row, 0, cols - 1); + } + } + + private void deleteLines(int count) { + count = Math.min(Math.max(1, count), rows - cursorRow); + for (int row = cursorRow; row + count < rows; row++) { + copyLine(row, row + count); + } + for (int row = rows - count; row < rows; row++) { + clearLine(row, 0, cols - 1); + } + } + + private void clearScreen() { + for (int row = 0; row < rows; row++) { + clearLine(row, 0, cols - 1); + } + } + + private void clearLine(int row, int start, int end) { + start = clamp(start, 0, cols - 1); + end = clamp(end, 0, cols - 1); + for (int col = start; col <= end; col++) { + cells[row][col].clear(); + } + } + + private void scrollUp(int count) { + count = Math.min(Math.max(1, count), rows); + for (int row = 0; row + count < rows; row++) { + copyLine(row, row + count); + } + for (int row = rows - count; row < rows; row++) { + clearLine(row, 0, cols - 1); + } + } + + private void scrollDown(int count) { + count = Math.min(Math.max(1, count), rows); + for (int row = rows - 1; row - count >= 0; row--) { + copyLine(row, row - count); + } + for (int row = 0; row < count; row++) { + clearLine(row, 0, cols - 1); + } + } + + private void copyLine(int target, int source) { + for (int col = 0; col < cols; col++) { + cells[target][col].copyFrom(cells[source][col]); + } + } + + private void appendRow(SpannableStringBuilder output, int row) { + int col = 0; + while (col < cols) { + Cell first = cells[row][col]; + int start = output.length(); + int fgColor = first.fg; + int bgColor = first.bg; + boolean isBold = first.bold; + while (col < cols) { + Cell cell = cells[row][col]; + if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold) { + break; + } + output.append(cell.value); + col++; + } + int end = output.length(); + output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (bgColor != DEFAULT_BG) { + output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (isBold) { + output.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + private void saveCursor() { + savedRow = cursorRow; + savedCol = cursorCol; + } + + private void restoreCursor() { + cursorRow = clamp(savedRow, 0, rows - 1); + cursorCol = clamp(savedCol, 0, cols - 1); + } + + private int skipStringEscape(String text, int start) { + int cursor = start; + while (cursor < text.length()) { + char item = text.charAt(cursor); + if (item == '\u0007') { + return cursor + 1; + } + if (item == '\u001b' && cursor + 1 < text.length() && text.charAt(cursor + 1) == '\\') { + return cursor + 2; + } + cursor++; + } + return -1; + } + + private int findAnsiEnd(String text, int start) { + for (int index = start; index < text.length(); index++) { + char item = text.charAt(index); + if (item >= '@' && item <= '~') { + return index; + } + } + return -1; + } + + private List parseParams(String params) { + List values = new ArrayList<>(); + if (params.isEmpty()) { + return values; + } + String[] parts = params.split(";", -1); + for (String part : parts) { + String cleaned = part.trim(); + if (cleaned.isEmpty()) { + values.add(0); + continue; + } + try { + values.add(Integer.parseInt(cleaned)); + } catch (NumberFormatException ignored) { + values.add(0); + } + } + return values; + } + + private int param(List values, int index, int fallback) { + if (index >= values.size()) { + return fallback; + } + int value = values.get(index); + return value == 0 ? fallback : value; + } + + private int ansiColor(int code, boolean background) { + int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30); + boolean bright = code >= 90; + switch (base) { + case 0: + return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45); + case 1: + return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73); + case 2: + return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80); + case 3: + return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34); + case 4: + return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255); + case 5: + return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255); + case 6: + return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187); + case 7: + default: + return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217); + } + } + + private int xtermColor(int value) { + value = clamp(value, 0, 255); + if (value < 16) { + if (value < 8) { + return ansiColor(30 + value, false); + } + return ansiColor(90 + value - 8, false); + } + if (value >= 232) { + int shade = 8 + (value - 232) * 10; + return Color.rgb(shade, shade, shade); + } + int index = value - 16; + int red = xtermComponent(index / 36); + int green = xtermComponent((index / 6) % 6); + int blue = xtermComponent(index % 6); + return Color.rgb(red, green, blue); + } + + private int xtermComponent(int value) { + return value == 0 ? 0 : 55 + value * 40; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static final class Cell { + char value = ' '; + int fg = DEFAULT_FG; + int bg = DEFAULT_BG; + boolean bold; + + void clear() { + value = ' '; + fg = DEFAULT_FG; + bg = DEFAULT_BG; + bold = false; + } + + void set(char nextValue, int nextFg, int nextBg, boolean nextBold) { + value = nextValue; + fg = nextFg; + bg = nextBg; + bold = nextBold; + } + + void copyFrom(Cell other) { + value = other.value; + fg = other.fg; + bg = other.bg; + bold = other.bold; + } + } +}