From f7c690d6e8a551e87824e8f0f36597e94c689406 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 09:24:46 +0200 Subject: [PATCH 1/7] feat: optimize output rendering with direct byte buffer (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ByteArrayBuilder utility and direct byte-level rendering path to eliminate String allocations and charset encoding overhead in the terminal output hot path. Key changes: - Add ByteArrayBuilder with appendInt(), appendAscii(), appendUtf8(), and csi() methods for zero-allocation ANSI sequence construction - Add toAnsiBytes() to AttributedCharSequence for direct byte output - Optimize toAnsi() to eliminate String concatenation in color codes (e.g., "38;2;" + r + ";" + g + ";" + b → sb.append() chaining) - Wire Display.update() to accumulate all output (rawPrint + cursor positioning) in a ByteArrayBuilder for UTF-8 terminals, writing directly to terminal.output() and bypassing PrintWriter/ OutputStreamWriter entirely - Extract substituteChar() and isBoxDrawing() helpers to share box-drawing character logic between toAnsi() and toAnsiBytes() --- .../jline/utils/AttributedCharSequence.java | 323 +++++++++++++++--- .../org/jline/utils/ByteArrayBuilder.java | 245 +++++++++++++ .../main/java/org/jline/utils/Display.java | 116 ++++++- .../org/jline/utils/ByteArrayBuilderTest.java | 232 +++++++++++++ 4 files changed, 856 insertions(+), 60 deletions(-) create mode 100644 terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java create mode 100644 terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index 86bbcaa3e..ea87e2856 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -253,40 +253,13 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a palette = ColorPalette.DEFAULT; } for (int i = 0; i < length(); i++) { - char c = charAt(i); + char c = substituteChar(charAt(i), altIn, altOut); if (altIn != null && altOut != null) { - char pc = c; - // @spotless:off - switch (c) { - case '┘': c = 'j'; break; - case '┐': c = 'k'; break; - case '┌': c = 'l'; break; - case '└': c = 'm'; break; - case '┼': c = 'n'; break; - case '─': c = 'q'; break; - case '├': c = 't'; break; - case '┤': c = 'u'; break; - case '┴': c = 'v'; break; - case '┬': c = 'w'; break; - case '│': c = 'x'; break; - } - // @spotless:on boolean oldalt = alt; - alt = c != pc; + alt = isBoxDrawing(charAt(i)); if (oldalt ^ alt) { sb.append(alt ? altIn : altOut); } - } else { - // Fallback to ASCII when alternate charset mode is not supported - // @spotless:off - switch (c) { - case '┘': case '┐': case '┌': case '└': c = '+'; break; - case '┼': c = '+'; break; - case '─': c = '-'; break; - case '├': case '┤': case '┴': case '┬': c = '+'; break; - case '│': c = '|'; break; - } - // @spotless:on } long s = styleCodeAt(i) & ~F_HIDDEN; // The hidden flag does not change the ansi styles if (style != s) { @@ -325,7 +298,7 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a int g = (int) (fg >> (FG_COLOR_EXP + 8)) & 0xFF; int b = (int) (fg >> FG_COLOR_EXP) & 0xFF; if (colors >= HIGH_COLORS) { - first = attr(sb, "38;2;" + r + ";" + g + ";" + b, first); + first = attrRgb(sb, 38, r, g, b, first); } else { rounded = palette.round(r, g, b); } @@ -338,15 +311,15 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a int r = (col >> 16) & 0xFF; int g = (col >> 8) & 0xFF; int b = col & 0xFF; - first = attr(sb, "38;2;" + r + ";" + g + ";" + b, first); + first = attrRgb(sb, 38, r, g, b, first); } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attr(sb, "38;5;" + rounded, first); + first = attrIdx(sb, 38, rounded, first); } else if (rounded >= 8) { - first = attr(sb, "9" + (rounded - 8), first); + first = attrInt(sb, 90 + rounded - 8, first); // small hack to force setting bold again after a foreground color change d |= (s & F_BOLD); } else { - first = attr(sb, "3" + rounded, first); + first = attrInt(sb, 30 + rounded, first); // small hack to force setting bold again after a foreground color change d |= (s & F_BOLD); } @@ -364,7 +337,7 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a int g = (int) (bg >> (BG_COLOR_EXP + 8)) & 0xFF; int b = (int) (bg >> BG_COLOR_EXP) & 0xFF; if (colors >= HIGH_COLORS) { - first = attr(sb, "48;2;" + r + ";" + g + ";" + b, first); + first = attrRgb(sb, 48, r, g, b, first); } else { rounded = palette.round(r, g, b); } @@ -377,13 +350,13 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a int r = (col >> 16) & 0xFF; int g = (col >> 8) & 0xFF; int b = col & 0xFF; - first = attr(sb, "48;2;" + r + ";" + g + ";" + b, first); + first = attrRgb(sb, 48, r, g, b, first); } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attr(sb, "48;5;" + rounded, first); + first = attrIdx(sb, 48, rounded, first); } else if (rounded >= 8) { - first = attr(sb, "10" + (rounded - 8), first); + first = attrInt(sb, 100 + rounded - 8, first); } else { - first = attr(sb, "4" + rounded, first); + first = attrInt(sb, 40 + rounded, first); } } } else { @@ -417,14 +390,280 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a return sb.toString(); } - private static boolean attr(StringBuilder sb, String s, boolean first) { - if (!first) { - sb.append(";"); + /** + * Writes the ANSI rendering of this attributed string directly as UTF-8 bytes + * to the given {@link ByteArrayBuilder}, avoiding intermediate String allocations. + * + *

This method produces output equivalent to + * {@link #toAnsi(int, ForceMode, ColorPalette, String, String)} but writes bytes + * directly, eliminating StringBuilder, Integer.toString(), and charset encoding overhead.

+ * + * @param buf the byte buffer to write to + * @param colors the number of colors to use + * @param force the force mode for color rendering + * @param palette the color palette, or null for default + * @param altIn the alternate charset enter sequence, or null + * @param altOut the alternate charset exit sequence, or null + */ + void toAnsiBytes( + ByteArrayBuilder buf, int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) { + long style = 0; + long foreground = 0; + long background = 0; + boolean alt = false; + if (palette == null) { + palette = ColorPalette.DEFAULT; + } + for (int i = 0; i < length(); i++) { + char c = charAt(i); + c = substituteChar(c, altIn, altOut); + if (altIn != null && altOut != null) { + boolean oldalt = alt; + alt = isBoxDrawing(charAt(i)); + if (oldalt ^ alt) { + buf.appendAscii(alt ? altIn : altOut); + } + } + long s = styleCodeAt(i) & ~F_HIDDEN; + if (style != s) { + long d = (style ^ s) & MASK; + long fg = (s & F_FOREGROUND) != 0 ? s & (FG_COLOR | F_FOREGROUND) : 0; + long bg = (s & F_BACKGROUND) != 0 ? s & (BG_COLOR | F_BACKGROUND) : 0; + if (s == 0) { + buf.csi().appendAscii("0m"); + foreground = background = 0; + } else { + buf.csi(); + boolean first = true; + if ((d & F_ITALIC) != 0) { + first = attrB(buf, (s & F_ITALIC) != 0 ? "3" : "23", first); + } + if ((d & F_UNDERLINE) != 0) { + first = attrB(buf, (s & F_UNDERLINE) != 0 ? "4" : "24", first); + } + if ((d & F_BLINK) != 0) { + first = attrB(buf, (s & F_BLINK) != 0 ? "5" : "25", first); + } + if ((d & F_INVERSE) != 0) { + first = attrB(buf, (s & F_INVERSE) != 0 ? "7" : "27", first); + } + if ((d & F_CONCEAL) != 0) { + first = attrB(buf, (s & F_CONCEAL) != 0 ? "8" : "28", first); + } + if ((d & F_CROSSED_OUT) != 0) { + first = attrB(buf, (s & F_CROSSED_OUT) != 0 ? "9" : "29", first); + } + if (foreground != fg) { + if (fg > 0) { + int rounded = -1; + if ((fg & F_FOREGROUND_RGB) != 0) { + int r = (int) (fg >> (FG_COLOR_EXP + 16)) & 0xFF; + int g = (int) (fg >> (FG_COLOR_EXP + 8)) & 0xFF; + int b = (int) (fg >> FG_COLOR_EXP) & 0xFF; + if (colors >= HIGH_COLORS) { + first = attrRgbB(buf, 38, r, g, b, first); + } else { + rounded = palette.round(r, g, b); + } + } else if ((fg & F_FOREGROUND_IND) != 0) { + rounded = palette.round((int) (fg >> FG_COLOR_EXP) & 0xFF); + } + if (rounded >= 0) { + if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { + int col = palette.getColor(rounded); + int r = (col >> 16) & 0xFF; + int g = (col >> 8) & 0xFF; + int b = col & 0xFF; + first = attrRgbB(buf, 38, r, g, b, first); + } else if (force == ForceMode.Force256Colors || rounded >= 16) { + first = attrIdxB(buf, 38, rounded, first); + } else if (rounded >= 8) { + first = attrIntB(buf, 90 + rounded - 8, first); + d |= (s & F_BOLD); + } else { + first = attrIntB(buf, 30 + rounded, first); + d |= (s & F_BOLD); + } + } + } else { + first = attrB(buf, "39", first); + } + foreground = fg; + } + if (background != bg) { + if (bg > 0) { + int rounded = -1; + if ((bg & F_BACKGROUND_RGB) != 0) { + int r = (int) (bg >> (BG_COLOR_EXP + 16)) & 0xFF; + int g = (int) (bg >> (BG_COLOR_EXP + 8)) & 0xFF; + int b = (int) (bg >> BG_COLOR_EXP) & 0xFF; + if (colors >= HIGH_COLORS) { + first = attrRgbB(buf, 48, r, g, b, first); + } else { + rounded = palette.round(r, g, b); + } + } else if ((bg & F_BACKGROUND_IND) != 0) { + rounded = palette.round((int) (bg >> BG_COLOR_EXP) & 0xFF); + } + if (rounded >= 0) { + if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { + int col = palette.getColor(rounded); + int r = (col >> 16) & 0xFF; + int g = (col >> 8) & 0xFF; + int b = col & 0xFF; + first = attrRgbB(buf, 48, r, g, b, first); + } else if (force == ForceMode.Force256Colors || rounded >= 16) { + first = attrIdxB(buf, 48, rounded, first); + } else if (rounded >= 8) { + first = attrIntB(buf, 100 + rounded - 8, first); + } else { + first = attrIntB(buf, 40 + rounded, first); + } + } + } else { + first = attrB(buf, "49", first); + } + background = bg; + } + if ((d & (F_BOLD | F_FAINT)) != 0) { + if ((d & F_BOLD) != 0 && (s & F_BOLD) == 0 || (d & F_FAINT) != 0 && (s & F_FAINT) == 0) { + first = attrB(buf, "22", first); + } + if ((d & F_BOLD) != 0 && (s & F_BOLD) != 0) { + first = attrB(buf, "1", first); + } + if ((d & F_FAINT) != 0 && (s & F_FAINT) != 0) { + first = attrB(buf, "2", first); + } + } + buf.appendAscii('m'); + } + style = s; + } + if (Character.isHighSurrogate(c) && i + 1 < length()) { + char next = charAt(i + 1); + if (Character.isLowSurrogate(next)) { + buf.appendUtf8(Character.toCodePoint(c, next)); + i++; + continue; + } + } + buf.appendUtf8(c); } + if (alt) { + buf.appendAscii(altOut); + } + if (style != 0) { + buf.csi().appendAscii("0m"); + } + } + + // @spotless:off + /** + * Substitutes box-drawing characters with alternate charset or ASCII equivalents. + */ + private static char substituteChar(char c, String altIn, String altOut) { + if (altIn != null && altOut != null) { + switch (c) { + case '┘': return 'j'; + case '┐': return 'k'; + case '┌': return 'l'; + case '└': return 'm'; + case '┼': return 'n'; + case '─': return 'q'; + case '├': return 't'; + case '┤': return 'u'; + case '┴': return 'v'; + case '┬': return 'w'; + case '│': return 'x'; + default: return c; + } + } else { + switch (c) { + case '┘': case '┐': case '┌': case '└': return '+'; + case '┼': return '+'; + case '─': return '-'; + case '├': case '┤': case '┴': case '┬': return '+'; + case '│': return '|'; + default: return c; + } + } + } + + private static boolean isBoxDrawing(char c) { + switch (c) { + case '┘': case '┐': case '┌': case '└': + case '┼': case '─': case '├': case '┤': + case '┴': case '┬': case '│': + return true; + default: + return false; + } + } + // @spotless:on + + // StringBuilder helpers — no String concatenation for color codes + private static boolean attr(StringBuilder sb, String s, boolean first) { + if (!first) sb.append(';'); sb.append(s); return false; } + private static boolean attrInt(StringBuilder sb, int value, boolean first) { + if (!first) sb.append(';'); + sb.append(value); + return false; + } + + private static boolean attrRgb(StringBuilder sb, int prefix, int r, int g, int b, boolean first) { + if (!first) sb.append(';'); + sb.append(prefix) + .append(";2;") + .append(r) + .append(';') + .append(g) + .append(';') + .append(b); + return false; + } + + private static boolean attrIdx(StringBuilder sb, int prefix, int idx, boolean first) { + if (!first) sb.append(';'); + sb.append(prefix).append(";5;").append(idx); + return false; + } + + // ByteArrayBuilder helpers — zero-allocation integer formatting + private static boolean attrB(ByteArrayBuilder buf, String s, boolean first) { + if (!first) buf.appendAscii(';'); + buf.appendAscii(s); + return false; + } + + private static boolean attrIntB(ByteArrayBuilder buf, int value, boolean first) { + if (!first) buf.appendAscii(';'); + buf.appendInt(value); + return false; + } + + private static boolean attrRgbB(ByteArrayBuilder buf, int prefix, int r, int g, int b, boolean first) { + if (!first) buf.appendAscii(';'); + buf.appendInt(prefix) + .appendAscii(";2;") + .appendInt(r) + .appendAscii(';') + .appendInt(g) + .appendAscii(';') + .appendInt(b); + return false; + } + + private static boolean attrIdxB(ByteArrayBuilder buf, int prefix, int idx, boolean first) { + if (!first) buf.appendAscii(';'); + buf.appendInt(prefix).appendAscii(";5;").appendInt(idx); + return false; + } + /** * Returns the style at the specified index in this attributed string. * diff --git a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java new file mode 100644 index 000000000..235326512 --- /dev/null +++ b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.utils; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * A reusable byte buffer optimized for building ANSI escape sequences and terminal output. + * + *

+ * This class provides efficient methods for constructing terminal output as raw bytes, + * bypassing the overhead of {@link StringBuilder} and charset encoding for content that + * is overwhelmingly ASCII (ANSI escape sequences, color codes, cursor positioning). + *

+ * + *

Key optimizations over String-based output:

+ * + */ +public class ByteArrayBuilder { + + private byte[] buffer; + private int count; + + public ByteArrayBuilder() { + this(256); + } + + public ByteArrayBuilder(int initialCapacity) { + buffer = new byte[initialCapacity]; + } + + private void ensureCapacity(int minCapacity) { + if (minCapacity > buffer.length) { + int newCapacity = Math.max(buffer.length << 1, minCapacity); + byte[] newBuffer = new byte[newCapacity]; + System.arraycopy(buffer, 0, newBuffer, 0, count); + buffer = newBuffer; + } + } + + /** + * Appends a CSI (Control Sequence Introducer) escape: ESC [ + */ + public ByteArrayBuilder csi() { + ensureCapacity(count + 2); + buffer[count++] = 0x1b; + buffer[count++] = '['; + return this; + } + + /** + * Appends a single ASCII character as a byte. + */ + public ByteArrayBuilder appendAscii(char c) { + ensureCapacity(count + 1); + buffer[count++] = (byte) c; + return this; + } + + /** + * Appends an ASCII string directly as bytes, bypassing charset encoding. + * The string must contain only ASCII characters (0x00-0x7F). + */ + public ByteArrayBuilder appendAscii(String s) { + int len = s.length(); + ensureCapacity(count + len); + for (int i = 0; i < len; i++) { + buffer[count++] = (byte) s.charAt(i); + } + return this; + } + + /** + * Appends an integer as ASCII digit bytes without creating a String. + * Optimized for the common case of small values (0-999) used in ANSI color codes. + */ + public ByteArrayBuilder appendInt(int value) { + if (value < 0) { + appendAscii('-'); + appendInt(-value); + return this; + } + if (value < 10) { + ensureCapacity(count + 1); + buffer[count++] = (byte) ('0' + value); + } else if (value < 100) { + ensureCapacity(count + 2); + buffer[count++] = (byte) ('0' + value / 10); + buffer[count++] = (byte) ('0' + value % 10); + } else if (value < 1000) { + ensureCapacity(count + 3); + buffer[count++] = (byte) ('0' + value / 100); + buffer[count++] = (byte) ('0' + (value / 10) % 10); + buffer[count++] = (byte) ('0' + value % 10); + } else { + appendAscii(Integer.toString(value)); + } + return this; + } + + /** + * Appends a character as UTF-8 encoded bytes. + * Fast-paths ASCII characters (single byte). + */ + public ByteArrayBuilder appendUtf8(char c) { + if (c < 0x80) { + ensureCapacity(count + 1); + buffer[count++] = (byte) c; + } else if (c < 0x800) { + ensureCapacity(count + 2); + buffer[count++] = (byte) (0xC0 | (c >> 6)); + buffer[count++] = (byte) (0x80 | (c & 0x3F)); + } else { + ensureCapacity(count + 3); + buffer[count++] = (byte) (0xE0 | (c >> 12)); + buffer[count++] = (byte) (0x80 | ((c >> 6) & 0x3F)); + buffer[count++] = (byte) (0x80 | (c & 0x3F)); + } + return this; + } + + /** + * Appends a Unicode code point as UTF-8 encoded bytes. + * Handles supplementary characters (code points above U+FFFF). + */ + public ByteArrayBuilder appendUtf8(int codePoint) { + if (codePoint < 0x80) { + ensureCapacity(count + 1); + buffer[count++] = (byte) codePoint; + } else if (codePoint < 0x800) { + ensureCapacity(count + 2); + buffer[count++] = (byte) (0xC0 | (codePoint >> 6)); + buffer[count++] = (byte) (0x80 | (codePoint & 0x3F)); + } else if (codePoint < 0x10000) { + ensureCapacity(count + 3); + buffer[count++] = (byte) (0xE0 | (codePoint >> 12)); + buffer[count++] = (byte) (0x80 | ((codePoint >> 6) & 0x3F)); + buffer[count++] = (byte) (0x80 | (codePoint & 0x3F)); + } else { + ensureCapacity(count + 4); + buffer[count++] = (byte) (0xF0 | (codePoint >> 18)); + buffer[count++] = (byte) (0x80 | ((codePoint >> 12) & 0x3F)); + buffer[count++] = (byte) (0x80 | ((codePoint >> 6) & 0x3F)); + buffer[count++] = (byte) (0x80 | (codePoint & 0x3F)); + } + return this; + } + + /** + * Returns the internal buffer. Only bytes from index 0 to {@link #length()} - 1 are valid. + */ + public byte[] buffer() { + return buffer; + } + + /** + * Returns the number of bytes written to the buffer. + */ + public int length() { + return count; + } + + /** + * Resets the write position to zero without deallocating the buffer. + */ + public void reset() { + count = 0; + } + + /** + * Returns a copy of the buffer contents as a byte array. + */ + public byte[] toByteArray() { + byte[] result = new byte[count]; + System.arraycopy(buffer, 0, result, 0, count); + return result; + } + + /** + * Writes the buffer contents to an output stream. + */ + public void writeTo(OutputStream out) throws IOException { + if (count > 0) { + out.write(buffer, 0, count); + } + } + + /** + * Returns the buffer contents as a UTF-8 string. + */ + public String toStringUtf8() { + return new String(buffer, 0, count, StandardCharsets.UTF_8); + } + + /** + * Returns an {@link Appendable} view that writes ASCII characters to this builder. + * Suitable for use with {@link Curses#tputs(Appendable, String, Object...)} since + * terminal capability sequences are pure ASCII. + */ + public Appendable asAsciiAppendable() { + return new AsciiAppendable(); + } + + private class AsciiAppendable implements Appendable { + @Override + public Appendable append(CharSequence csq) { + int len = csq.length(); + ensureCapacity(count + len); + for (int i = 0; i < len; i++) { + buffer[count++] = (byte) csq.charAt(i); + } + return this; + } + + @Override + public Appendable append(CharSequence csq, int start, int end) { + int len = end - start; + ensureCapacity(count + len); + for (int i = start; i < end; i++) { + buffer[count++] = (byte) csq.charAt(i); + } + return this; + } + + @Override + public Appendable append(char c) { + ensureCapacity(count + 1); + buffer[count++] = (byte) c; + return this; + } + } +} diff --git a/terminal/src/main/java/org/jline/utils/Display.java b/terminal/src/main/java/org/jline/utils/Display.java index cb5782a02..df6862a9c 100644 --- a/terminal/src/main/java/org/jline/utils/Display.java +++ b/terminal/src/main/java/org/jline/utils/Display.java @@ -8,6 +8,9 @@ */ package org.jline.utils; +import java.io.IOError; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -93,6 +96,18 @@ public class Display { protected boolean delayedWrapAtEol; protected final boolean cursorDownIsNewLine; + // Byte-mode fields: when the terminal uses UTF-8, we accumulate all output + // in a ByteArrayBuilder and write directly to terminal.output(), bypassing + // PrintWriter/OutputStreamWriter overhead. + private ByteArrayBuilder byteBuilder; + private Appendable byteAppendable; + private boolean useByteMode; + private int ansiColors; + private AttributedCharSequence.ForceMode ansiForceMode; + private ColorPalette ansiPalette; + private String ansiAltIn; + private String ansiAltOut; + @SuppressWarnings("this-escape") public Display(Terminal terminal, boolean fullscreen) { this.terminal = terminal; @@ -180,15 +195,37 @@ public void update(List newLines, int targetCursorPos) { * @param flush whether the output should be flushed or not */ public void update(List newLines, int targetCursorPos, boolean flush) { + // Set up byte mode: accumulate all output in a byte buffer for UTF-8 terminals. + // This avoids String allocations and charset encoding for ANSI escape sequences. + Integer cols = terminal.getNumericCapability(Capability.max_colors); + useByteMode = (cols != null && cols >= 8) && StandardCharsets.UTF_8.equals(terminal.outputEncoding()); + if (useByteMode) { + if (byteBuilder == null) { + byteBuilder = new ByteArrayBuilder(4096); + byteAppendable = byteBuilder.asAsciiAppendable(); + } else { + byteBuilder.reset(); + } + ansiColors = cols; + ansiForceMode = AttributedCharSequence.ForceMode.None; + ansiPalette = terminal.getPalette(); + if (!AttributedCharSequence.DISABLE_ALTERNATE_CHARSET) { + ansiAltIn = Curses.tputs(terminal.getStringCapability(Capability.enter_alt_charset_mode)); + ansiAltOut = Curses.tputs(terminal.getStringCapability(Capability.exit_alt_charset_mode)); + } else { + ansiAltIn = null; + ansiAltOut = null; + } + } + if (reset) { - terminal.puts(Capability.clear_screen); + puts(Capability.clear_screen); oldLines.clear(); cursorPos = 0; reset = false; } // If dumb display, get rid of ansi sequences now - Integer cols = terminal.getNumericCapability(Capability.max_colors); if (cols == null || cols < 8) { newLines = newLines.stream() .map(s -> new AttributedString(s.toString())) @@ -276,7 +313,7 @@ public void update(List newLines, int targetCursorPos, boolean if (newLength == 0 || newLine.isHidden(0)) { // go to next line column zero rawPrint(' '); - terminal.puts(Capability.cursor_left); + puts(Capability.cursor_left); } else { AttributedString firstChar = newLine.substring(0, 1); // go to next line column one @@ -297,7 +334,7 @@ public void update(List newLines, int targetCursorPos, boolean // based on char-level diffs. Force a full line repaint in this case. if (terminal.getGraphemeClusterMode() && !oldLine.equals(newLine)) { cursorPos = moveVisualCursorTo(currentPos); - if (!terminal.puts(Capability.clr_eol)) { + if (!puts(Capability.clr_eol)) { int oldLen = oldLine.columnLength(terminal); if (oldLen > 0) { rawPrint(' ', oldLen); @@ -375,7 +412,7 @@ public void update(List newLines, int targetCursorPos, boolean int newLen = newLine.columnLength(terminal); int nb = Math.max(oldLen, newLen) - (currentPos - curCol); moveVisualCursorTo(currentPos); - if (!terminal.puts(Capability.clr_eol)) { + if (!puts(Capability.clr_eol)) { rawPrint(' ', nb); cursorPos += nb; } @@ -394,17 +431,17 @@ public void update(List newLines, int targetCursorPos, boolean if (newWrap != oldWrap && !(oldWrap && cleared)) { moveVisualCursorTo(lineIndex * columns1 - 1, newLines); if (newWrap) wrapNeeded = true; - else terminal.puts(Capability.clr_eol); + else puts(Capability.clr_eol); } } else if (atRight) { if (this.wrapAtEol) { if (!fullScreen || (fullScreen && lineIndex < numLines)) { rawPrint(' '); - terminal.puts(Capability.cursor_left); + puts(Capability.cursor_left); cursorPos++; } } else { - terminal.puts(Capability.carriage_return); // CR / not newline. + puts(Capability.carriage_return); // CR / not newline. cursorPos = curCol; } currentPos = cursorPos; @@ -415,9 +452,27 @@ public void update(List newLines, int targetCursorPos, boolean } oldLines = newLines; + if (useByteMode && byteBuilder.length() > 0) { + // Flush any pending writer data, then write accumulated bytes + terminal.writer().flush(); + try { + byteBuilder.writeTo(terminal.output()); + } catch (IOException e) { + throw new IOError(e); + } + } if (flush) { - terminal.flush(); + if (useByteMode) { + try { + terminal.output().flush(); + } catch (IOException e) { + throw new IOError(e); + } + } else { + terminal.flush(); + } } + useByteMode = false; } protected boolean deleteLines(int nb) { @@ -444,11 +499,11 @@ protected boolean perform(Capability single, Capability multi, int nb) { boolean hasMulti = terminal.getStringCapability(multi) != null; boolean hasSingle = terminal.getStringCapability(single) != null; if (hasMulti && (!hasSingle || cost(single) * nb > cost(multi))) { - terminal.puts(multi, nb); + puts(multi, nb); return true; } else if (hasSingle) { for (int i = 0; i < nb; i++) { - terminal.puts(single); + puts(single); } return true; } else { @@ -525,7 +580,7 @@ protected int moveVisualCursorTo(int i1) { int l1 = i1 / width; int c1 = i1 % width; if (c0 == columns) { // at right margin - terminal.puts(Capability.carriage_return); + puts(Capability.carriage_return); c0 = 0; } if (l0 > l1) { @@ -533,22 +588,22 @@ protected int moveVisualCursorTo(int i1) { } else if (l0 < l1) { // TODO: clean the following if (fullScreen) { - if (!terminal.puts(Capability.parm_down_cursor, l1 - l0)) { + if (!puts(Capability.parm_down_cursor, l1 - l0)) { for (int i = l0; i < l1; i++) { - terminal.puts(Capability.cursor_down); + puts(Capability.cursor_down); } if (cursorDownIsNewLine) { c0 = 0; } } } else { - terminal.puts(Capability.carriage_return); + puts(Capability.carriage_return); rawPrint('\n', l1 - l0); c0 = 0; } } if (c0 != 0 && c1 == 0) { - terminal.puts(Capability.carriage_return); + puts(Capability.carriage_return); } else if (c0 < c1) { perform(Capability.cursor_right, Capability.parm_right_cursor, c1 - c0); } else if (c0 > c1) { @@ -565,11 +620,36 @@ void rawPrint(char c, int num) { } void rawPrint(int c) { - terminal.writer().write(c); + if (useByteMode) { + byteBuilder.appendUtf8(c); + } else { + terminal.writer().write(c); + } } void rawPrint(AttributedString str) { - str.print(terminal); + if (useByteMode) { + str.toAnsiBytes(byteBuilder, ansiColors, ansiForceMode, ansiPalette, ansiAltIn, ansiAltOut); + } else { + str.print(terminal); + } + } + + /** + * Writes a terminal capability sequence. In byte mode, writes directly to the + * byte buffer via Curses; otherwise delegates to terminal.puts(). + */ + private boolean puts(Capability capability, Object... params) { + if (useByteMode) { + String str = terminal.getStringCapability(capability); + if (str == null) { + return false; + } + Curses.tputs(byteAppendable, str, params); + return true; + } else { + return terminal.puts(capability, params); + } } public int wcwidth(String str) { diff --git a/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java new file mode 100644 index 000000000..21692240c --- /dev/null +++ b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ByteArrayBuilderTest { + + @Test + public void testAppendAsciiChar() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendAscii('A').appendAscii('B').appendAscii('C'); + assertEquals("ABC", buf.toStringUtf8()); + } + + @Test + public void testAppendAsciiString() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendAscii("hello world"); + assertEquals("hello world", buf.toStringUtf8()); + } + + @Test + public void testCsi() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.csi().appendAscii("0m"); + assertEquals("\033[0m", buf.toStringUtf8()); + } + + @Test + public void testAppendIntSmallValues() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendInt(0); + assertEquals("0", buf.toStringUtf8()); + + buf.reset(); + buf.appendInt(5); + assertEquals("5", buf.toStringUtf8()); + + buf.reset(); + buf.appendInt(42); + assertEquals("42", buf.toStringUtf8()); + + buf.reset(); + buf.appendInt(255); + assertEquals("255", buf.toStringUtf8()); + } + + @Test + public void testAppendIntLargeValues() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendInt(1000); + assertEquals("1000", buf.toStringUtf8()); + + buf.reset(); + buf.appendInt(65535); + assertEquals("65535", buf.toStringUtf8()); + } + + @Test + public void testAppendIntNegative() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendInt(-1); + assertEquals("-1", buf.toStringUtf8()); + + buf.reset(); + buf.appendInt(-128); + assertEquals("-128", buf.toStringUtf8()); + } + + @Test + public void testAppendUtf8Ascii() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendUtf8('A'); + assertArrayEquals(new byte[] {'A'}, buf.toByteArray()); + } + + @Test + public void testAppendUtf8TwoByte() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendUtf8('\u00E9'); // é + assertEquals("é", buf.toStringUtf8()); + assertArrayEquals("é".getBytes(StandardCharsets.UTF_8), buf.toByteArray()); + } + + @Test + public void testAppendUtf8ThreeByte() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendUtf8('\u4E16'); // 世 + assertEquals("世", buf.toStringUtf8()); + assertArrayEquals("世".getBytes(StandardCharsets.UTF_8), buf.toByteArray()); + } + + @Test + public void testAppendUtf8SupplementaryCodePoint() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + int cp = 0x1F600; // 😀 + buf.appendUtf8(cp); + String expected = new String(Character.toChars(cp)); + assertEquals(expected, buf.toStringUtf8()); + assertArrayEquals(expected.getBytes(StandardCharsets.UTF_8), buf.toByteArray()); + } + + @Test + public void testAnsiColorSequence() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + // Build: \033[38;2;128;64;255m + buf.csi() + .appendInt(38) + .appendAscii(";2;") + .appendInt(128) + .appendAscii(';') + .appendInt(64) + .appendAscii(';') + .appendInt(255) + .appendAscii('m'); + assertEquals("\033[38;2;128;64;255m", buf.toStringUtf8()); + } + + @Test + public void testReset() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendAscii("first"); + assertEquals(5, buf.length()); + buf.reset(); + assertEquals(0, buf.length()); + buf.appendAscii("second"); + assertEquals("second", buf.toStringUtf8()); + } + + @Test + public void testWriteTo() throws IOException { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendAscii("test"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + buf.writeTo(out); + assertArrayEquals("test".getBytes(StandardCharsets.UTF_8), out.toByteArray()); + } + + @Test + public void testGrowth() { + ByteArrayBuilder buf = new ByteArrayBuilder(4); + StringBuilder expected = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + buf.appendAscii('x'); + expected.append('x'); + } + assertEquals(1000, buf.length()); + assertEquals(expected.toString(), buf.toStringUtf8()); + } + + @Test + public void testAsAsciiAppendable() throws IOException { + ByteArrayBuilder buf = new ByteArrayBuilder(); + Appendable app = buf.asAsciiAppendable(); + app.append('A'); + app.append("BCD"); + app.append("xyzzy", 1, 4); + assertEquals("ABCDyzz", buf.toStringUtf8()); + } + + @Test + public void testToAnsiBytesParity() { + // Verify that toAnsiBytes produces the same output as toAnsi + AttributedStringBuilder asb = new AttributedStringBuilder(); + asb.style(AttributedStyle.DEFAULT.foreground(128, 64, 255)); + asb.append("colored"); + asb.style(AttributedStyle.DEFAULT.bold()); + asb.append(" bold"); + asb.style(AttributedStyle.DEFAULT); + asb.append(" plain"); + AttributedString str = asb.toAttributedString(); + + String ansiString = str.toAnsi(AttributedCharSequence.TRUE_COLORS, AttributedCharSequence.ForceMode.None); + + ByteArrayBuilder buf = new ByteArrayBuilder(); + str.toAnsiBytes( + buf, AttributedCharSequence.TRUE_COLORS, AttributedCharSequence.ForceMode.None, null, null, null); + String bytesAsString = buf.toStringUtf8(); + + assertEquals(ansiString, bytesAsString); + } + + @Test + public void testToAnsiBytesParityWithIndexedColors() { + AttributedStringBuilder asb = new AttributedStringBuilder(); + asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)); + asb.append("red"); + asb.style(AttributedStyle.DEFAULT.background(AttributedStyle.BLUE)); + asb.append("blue-bg"); + asb.style(AttributedStyle.DEFAULT.italic()); + asb.append("italic"); + asb.style(AttributedStyle.DEFAULT); + AttributedString str = asb.toAttributedString(); + + String ansiString = str.toAnsi(256, AttributedCharSequence.ForceMode.None); + + ByteArrayBuilder buf = new ByteArrayBuilder(); + str.toAnsiBytes(buf, 256, AttributedCharSequence.ForceMode.None, null, null, null); + + assertEquals(ansiString, buf.toStringUtf8()); + } + + @Test + public void testToAnsiBytesWithMultiByteChars() { + AttributedStringBuilder asb = new AttributedStringBuilder(); + asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN)); + asb.append("hello 世界"); + asb.style(AttributedStyle.DEFAULT); + AttributedString str = asb.toAttributedString(); + + String ansiString = str.toAnsi(256, AttributedCharSequence.ForceMode.None); + + ByteArrayBuilder buf = new ByteArrayBuilder(); + str.toAnsiBytes(buf, 256, AttributedCharSequence.ForceMode.None, null, null, null); + + assertEquals(ansiString, buf.toStringUtf8()); + } +} From de5f503d4c32f9c9bde4211216c7ebc6a3d45ba1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 12:16:59 +0200 Subject: [PATCH 2/7] fix: address review feedback for byte buffer optimization - Fix appendInt(Integer.MIN_VALUE) infinite recursion in ByteArrayBuilder - Eliminate code duplication by having toAnsi() delegate to toAnsiBytes() - Remove unused StringBuilder helper methods (attr, attrInt, attrRgb, attrIdx) - Add clarifying comment on surrogate pair skip - Add test for Integer.MIN_VALUE edge case --- .../jline/utils/AttributedCharSequence.java | 180 +----------------- .../org/jline/utils/ByteArrayBuilder.java | 3 + .../org/jline/utils/ByteArrayBuilderTest.java | 7 + 3 files changed, 14 insertions(+), 176 deletions(-) diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index ea87e2856..e6e02b864 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -244,150 +244,9 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette) { * @return a string with ANSI escape sequences representing this attributed string */ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) { - StringBuilder sb = new StringBuilder(); - long style = 0; - long foreground = 0; - long background = 0; - boolean alt = false; - if (palette == null) { - palette = ColorPalette.DEFAULT; - } - for (int i = 0; i < length(); i++) { - char c = substituteChar(charAt(i), altIn, altOut); - if (altIn != null && altOut != null) { - boolean oldalt = alt; - alt = isBoxDrawing(charAt(i)); - if (oldalt ^ alt) { - sb.append(alt ? altIn : altOut); - } - } - long s = styleCodeAt(i) & ~F_HIDDEN; // The hidden flag does not change the ansi styles - if (style != s) { - long d = (style ^ s) & MASK; - long fg = (s & F_FOREGROUND) != 0 ? s & (FG_COLOR | F_FOREGROUND) : 0; - long bg = (s & F_BACKGROUND) != 0 ? s & (BG_COLOR | F_BACKGROUND) : 0; - if (s == 0) { - sb.append("\033[0m"); - foreground = background = 0; - } else { - sb.append("\033["); - boolean first = true; - if ((d & F_ITALIC) != 0) { - first = attr(sb, (s & F_ITALIC) != 0 ? "3" : "23", first); - } - if ((d & F_UNDERLINE) != 0) { - first = attr(sb, (s & F_UNDERLINE) != 0 ? "4" : "24", first); - } - if ((d & F_BLINK) != 0) { - first = attr(sb, (s & F_BLINK) != 0 ? "5" : "25", first); - } - if ((d & F_INVERSE) != 0) { - first = attr(sb, (s & F_INVERSE) != 0 ? "7" : "27", first); - } - if ((d & F_CONCEAL) != 0) { - first = attr(sb, (s & F_CONCEAL) != 0 ? "8" : "28", first); - } - if ((d & F_CROSSED_OUT) != 0) { - first = attr(sb, (s & F_CROSSED_OUT) != 0 ? "9" : "29", first); - } - if (foreground != fg) { - if (fg > 0) { - int rounded = -1; - if ((fg & F_FOREGROUND_RGB) != 0) { - int r = (int) (fg >> (FG_COLOR_EXP + 16)) & 0xFF; - int g = (int) (fg >> (FG_COLOR_EXP + 8)) & 0xFF; - int b = (int) (fg >> FG_COLOR_EXP) & 0xFF; - if (colors >= HIGH_COLORS) { - first = attrRgb(sb, 38, r, g, b, first); - } else { - rounded = palette.round(r, g, b); - } - } else if ((fg & F_FOREGROUND_IND) != 0) { - rounded = palette.round((int) (fg >> FG_COLOR_EXP) & 0xFF); - } - if (rounded >= 0) { - if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { - int col = palette.getColor(rounded); - int r = (col >> 16) & 0xFF; - int g = (col >> 8) & 0xFF; - int b = col & 0xFF; - first = attrRgb(sb, 38, r, g, b, first); - } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attrIdx(sb, 38, rounded, first); - } else if (rounded >= 8) { - first = attrInt(sb, 90 + rounded - 8, first); - // small hack to force setting bold again after a foreground color change - d |= (s & F_BOLD); - } else { - first = attrInt(sb, 30 + rounded, first); - // small hack to force setting bold again after a foreground color change - d |= (s & F_BOLD); - } - } - } else { - first = attr(sb, "39", first); - } - foreground = fg; - } - if (background != bg) { - if (bg > 0) { - int rounded = -1; - if ((bg & F_BACKGROUND_RGB) != 0) { - int r = (int) (bg >> (BG_COLOR_EXP + 16)) & 0xFF; - int g = (int) (bg >> (BG_COLOR_EXP + 8)) & 0xFF; - int b = (int) (bg >> BG_COLOR_EXP) & 0xFF; - if (colors >= HIGH_COLORS) { - first = attrRgb(sb, 48, r, g, b, first); - } else { - rounded = palette.round(r, g, b); - } - } else if ((bg & F_BACKGROUND_IND) != 0) { - rounded = palette.round((int) (bg >> BG_COLOR_EXP) & 0xFF); - } - if (rounded >= 0) { - if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { - int col = palette.getColor(rounded); - int r = (col >> 16) & 0xFF; - int g = (col >> 8) & 0xFF; - int b = col & 0xFF; - first = attrRgb(sb, 48, r, g, b, first); - } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attrIdx(sb, 48, rounded, first); - } else if (rounded >= 8) { - first = attrInt(sb, 100 + rounded - 8, first); - } else { - first = attrInt(sb, 40 + rounded, first); - } - } - } else { - first = attr(sb, "49", first); - } - background = bg; - } - if ((d & (F_BOLD | F_FAINT)) != 0) { - if ((d & F_BOLD) != 0 && (s & F_BOLD) == 0 || (d & F_FAINT) != 0 && (s & F_FAINT) == 0) { - first = attr(sb, "22", first); - } - if ((d & F_BOLD) != 0 && (s & F_BOLD) != 0) { - first = attr(sb, "1", first); - } - if ((d & F_FAINT) != 0 && (s & F_FAINT) != 0) { - first = attr(sb, "2", first); - } - } - sb.append("m"); - } - style = s; - } - sb.append(c); - } - if (alt) { - sb.append(altOut); - } - if (style != 0) { - sb.append("\033[0m"); - } - return sb.toString(); + ByteArrayBuilder buf = new ByteArrayBuilder(); + toAnsiBytes(buf, colors, force, palette, altIn, altOut); + return buf.toStringUtf8(); } /** @@ -544,7 +403,7 @@ void toAnsiBytes( char next = charAt(i + 1); if (Character.isLowSurrogate(next)) { buf.appendUtf8(Character.toCodePoint(c, next)); - i++; + i++; // skip low surrogate continue; } } @@ -602,37 +461,6 @@ private static boolean isBoxDrawing(char c) { } // @spotless:on - // StringBuilder helpers — no String concatenation for color codes - private static boolean attr(StringBuilder sb, String s, boolean first) { - if (!first) sb.append(';'); - sb.append(s); - return false; - } - - private static boolean attrInt(StringBuilder sb, int value, boolean first) { - if (!first) sb.append(';'); - sb.append(value); - return false; - } - - private static boolean attrRgb(StringBuilder sb, int prefix, int r, int g, int b, boolean first) { - if (!first) sb.append(';'); - sb.append(prefix) - .append(";2;") - .append(r) - .append(';') - .append(g) - .append(';') - .append(b); - return false; - } - - private static boolean attrIdx(StringBuilder sb, int prefix, int idx, boolean first) { - if (!first) sb.append(';'); - sb.append(prefix).append(";5;").append(idx); - return false; - } - // ByteArrayBuilder helpers — zero-allocation integer formatting private static boolean attrB(ByteArrayBuilder buf, String s, boolean first) { if (!first) buf.appendAscii(';'); diff --git a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java index 235326512..107eb7006 100644 --- a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java +++ b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java @@ -89,6 +89,9 @@ public ByteArrayBuilder appendAscii(String s) { */ public ByteArrayBuilder appendInt(int value) { if (value < 0) { + if (value == Integer.MIN_VALUE) { + return appendAscii(Integer.toString(value)); + } appendAscii('-'); appendInt(-value); return this; diff --git a/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java index 21692240c..ec18c988e 100644 --- a/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java +++ b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java @@ -81,6 +81,13 @@ public void testAppendIntNegative() { assertEquals("-128", buf.toStringUtf8()); } + @Test + public void testAppendIntMinValue() { + ByteArrayBuilder buf = new ByteArrayBuilder(); + buf.appendInt(Integer.MIN_VALUE); + assertEquals(Integer.toString(Integer.MIN_VALUE), buf.toStringUtf8()); + } + @Test public void testAppendUtf8Ascii() { ByteArrayBuilder buf = new ByteArrayBuilder(); From 81f6fce08097bea86fb8ece8300810f7bb8be19c Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 13:46:01 +0200 Subject: [PATCH 3/7] fix: address SonarCloud issues in byte buffer optimization - Extract toAnsiBytes() into smaller methods to reduce cognitive complexity (emitStyleChange, appendDecorationAttrsB, appendColorB, appendRoundedColorB, usedBasicFgColor, appendBoldFaintB, emitAltCharset, emitUtf8Char) - Use while loop instead of for to avoid modifying loop counter (S127) - Remove public modifiers from test class and methods (S5786) --- .../jline/utils/AttributedCharSequence.java | 344 +++++++++++------- .../org/jline/utils/ByteArrayBuilderTest.java | 40 +- 2 files changed, 231 insertions(+), 153 deletions(-) diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index e6e02b864..ea889c444 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -267,147 +267,22 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a void toAnsiBytes( ByteArrayBuilder buf, int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) { long style = 0; - long foreground = 0; - long background = 0; + long[] colorState = {0, 0}; // [foreground, background] boolean alt = false; if (palette == null) { palette = ColorPalette.DEFAULT; } - for (int i = 0; i < length(); i++) { - char c = charAt(i); - c = substituteChar(c, altIn, altOut); - if (altIn != null && altOut != null) { - boolean oldalt = alt; - alt = isBoxDrawing(charAt(i)); - if (oldalt ^ alt) { - buf.appendAscii(alt ? altIn : altOut); - } - } + int i = 0; + int len = length(); + while (i < len) { + char c = substituteChar(charAt(i), altIn, altOut); + alt = emitAltCharset(buf, charAt(i), alt, altIn, altOut); long s = styleCodeAt(i) & ~F_HIDDEN; if (style != s) { - long d = (style ^ s) & MASK; - long fg = (s & F_FOREGROUND) != 0 ? s & (FG_COLOR | F_FOREGROUND) : 0; - long bg = (s & F_BACKGROUND) != 0 ? s & (BG_COLOR | F_BACKGROUND) : 0; - if (s == 0) { - buf.csi().appendAscii("0m"); - foreground = background = 0; - } else { - buf.csi(); - boolean first = true; - if ((d & F_ITALIC) != 0) { - first = attrB(buf, (s & F_ITALIC) != 0 ? "3" : "23", first); - } - if ((d & F_UNDERLINE) != 0) { - first = attrB(buf, (s & F_UNDERLINE) != 0 ? "4" : "24", first); - } - if ((d & F_BLINK) != 0) { - first = attrB(buf, (s & F_BLINK) != 0 ? "5" : "25", first); - } - if ((d & F_INVERSE) != 0) { - first = attrB(buf, (s & F_INVERSE) != 0 ? "7" : "27", first); - } - if ((d & F_CONCEAL) != 0) { - first = attrB(buf, (s & F_CONCEAL) != 0 ? "8" : "28", first); - } - if ((d & F_CROSSED_OUT) != 0) { - first = attrB(buf, (s & F_CROSSED_OUT) != 0 ? "9" : "29", first); - } - if (foreground != fg) { - if (fg > 0) { - int rounded = -1; - if ((fg & F_FOREGROUND_RGB) != 0) { - int r = (int) (fg >> (FG_COLOR_EXP + 16)) & 0xFF; - int g = (int) (fg >> (FG_COLOR_EXP + 8)) & 0xFF; - int b = (int) (fg >> FG_COLOR_EXP) & 0xFF; - if (colors >= HIGH_COLORS) { - first = attrRgbB(buf, 38, r, g, b, first); - } else { - rounded = palette.round(r, g, b); - } - } else if ((fg & F_FOREGROUND_IND) != 0) { - rounded = palette.round((int) (fg >> FG_COLOR_EXP) & 0xFF); - } - if (rounded >= 0) { - if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { - int col = palette.getColor(rounded); - int r = (col >> 16) & 0xFF; - int g = (col >> 8) & 0xFF; - int b = col & 0xFF; - first = attrRgbB(buf, 38, r, g, b, first); - } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attrIdxB(buf, 38, rounded, first); - } else if (rounded >= 8) { - first = attrIntB(buf, 90 + rounded - 8, first); - d |= (s & F_BOLD); - } else { - first = attrIntB(buf, 30 + rounded, first); - d |= (s & F_BOLD); - } - } - } else { - first = attrB(buf, "39", first); - } - foreground = fg; - } - if (background != bg) { - if (bg > 0) { - int rounded = -1; - if ((bg & F_BACKGROUND_RGB) != 0) { - int r = (int) (bg >> (BG_COLOR_EXP + 16)) & 0xFF; - int g = (int) (bg >> (BG_COLOR_EXP + 8)) & 0xFF; - int b = (int) (bg >> BG_COLOR_EXP) & 0xFF; - if (colors >= HIGH_COLORS) { - first = attrRgbB(buf, 48, r, g, b, first); - } else { - rounded = palette.round(r, g, b); - } - } else if ((bg & F_BACKGROUND_IND) != 0) { - rounded = palette.round((int) (bg >> BG_COLOR_EXP) & 0xFF); - } - if (rounded >= 0) { - if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { - int col = palette.getColor(rounded); - int r = (col >> 16) & 0xFF; - int g = (col >> 8) & 0xFF; - int b = col & 0xFF; - first = attrRgbB(buf, 48, r, g, b, first); - } else if (force == ForceMode.Force256Colors || rounded >= 16) { - first = attrIdxB(buf, 48, rounded, first); - } else if (rounded >= 8) { - first = attrIntB(buf, 100 + rounded - 8, first); - } else { - first = attrIntB(buf, 40 + rounded, first); - } - } - } else { - first = attrB(buf, "49", first); - } - background = bg; - } - if ((d & (F_BOLD | F_FAINT)) != 0) { - if ((d & F_BOLD) != 0 && (s & F_BOLD) == 0 || (d & F_FAINT) != 0 && (s & F_FAINT) == 0) { - first = attrB(buf, "22", first); - } - if ((d & F_BOLD) != 0 && (s & F_BOLD) != 0) { - first = attrB(buf, "1", first); - } - if ((d & F_FAINT) != 0 && (s & F_FAINT) != 0) { - first = attrB(buf, "2", first); - } - } - buf.appendAscii('m'); - } + emitStyleChange(buf, style, s, colorState, colors, force, palette); style = s; } - if (Character.isHighSurrogate(c) && i + 1 < length()) { - char next = charAt(i + 1); - if (Character.isLowSurrogate(next)) { - buf.appendUtf8(Character.toCodePoint(c, next)); - i++; // skip low surrogate - continue; - } - } - buf.appendUtf8(c); + i += emitUtf8Char(buf, c, i, len); } if (alt) { buf.appendAscii(altOut); @@ -417,6 +292,209 @@ void toAnsiBytes( } } + private static boolean emitAltCharset( + ByteArrayBuilder buf, char originalChar, boolean alt, String altIn, String altOut) { + if (altIn != null && altOut != null) { + boolean newAlt = isBoxDrawing(originalChar); + if (alt != newAlt) { + buf.appendAscii(newAlt ? altIn : altOut); + } + return newAlt; + } + return alt; + } + + private int emitUtf8Char(ByteArrayBuilder buf, char c, int i, int len) { + if (Character.isHighSurrogate(c) && i + 1 < len) { + char next = charAt(i + 1); + if (Character.isLowSurrogate(next)) { + buf.appendUtf8(Character.toCodePoint(c, next)); + return 2; + } + } + buf.appendUtf8(c); + return 1; + } + + private static void emitStyleChange( + ByteArrayBuilder buf, + long prevStyle, + long newStyle, + long[] colorState, + int colors, + ForceMode force, + ColorPalette palette) { + long fg = (newStyle & F_FOREGROUND) != 0 ? newStyle & (FG_COLOR | F_FOREGROUND) : 0; + long bg = (newStyle & F_BACKGROUND) != 0 ? newStyle & (BG_COLOR | F_BACKGROUND) : 0; + if (newStyle == 0) { + buf.csi().appendAscii("0m"); + colorState[0] = 0; + colorState[1] = 0; + return; + } + long d = (prevStyle ^ newStyle) & MASK; + buf.csi(); + boolean first = true; + first = appendDecorationAttrsB(buf, d, newStyle, first); + if (colorState[0] != fg) { + first = appendColorB( + buf, + fg, + F_FOREGROUND_RGB, + F_FOREGROUND_IND, + FG_COLOR_EXP, + 38, + 30, + 90, + "39", + colors, + force, + palette, + first); + if (fg > 0 && usedBasicFgColor(fg, colors, force, palette)) { + d |= (newStyle & F_BOLD); + } + colorState[0] = fg; + } + if (colorState[1] != bg) { + first = appendColorB( + buf, + bg, + F_BACKGROUND_RGB, + F_BACKGROUND_IND, + BG_COLOR_EXP, + 48, + 40, + 100, + "49", + colors, + force, + palette, + first); + colorState[1] = bg; + } + first = appendBoldFaintB(buf, d, newStyle, first); + buf.appendAscii('m'); + } + + private static boolean appendDecorationAttrsB(ByteArrayBuilder buf, long d, long s, boolean first) { + if ((d & F_ITALIC) != 0) { + first = attrB(buf, (s & F_ITALIC) != 0 ? "3" : "23", first); + } + if ((d & F_UNDERLINE) != 0) { + first = attrB(buf, (s & F_UNDERLINE) != 0 ? "4" : "24", first); + } + if ((d & F_BLINK) != 0) { + first = attrB(buf, (s & F_BLINK) != 0 ? "5" : "25", first); + } + if ((d & F_INVERSE) != 0) { + first = attrB(buf, (s & F_INVERSE) != 0 ? "7" : "27", first); + } + if ((d & F_CONCEAL) != 0) { + first = attrB(buf, (s & F_CONCEAL) != 0 ? "8" : "28", first); + } + if ((d & F_CROSSED_OUT) != 0) { + first = attrB(buf, (s & F_CROSSED_OUT) != 0 ? "9" : "29", first); + } + return first; + } + + private static boolean appendColorB( + ByteArrayBuilder buf, + long colorValue, + long rgbFlag, + long indFlag, + int colorExp, + int csiPrefix, + int lowBase, + int highBase, + String defaultCode, + int colors, + ForceMode force, + ColorPalette palette, + boolean first) { + if (colorValue <= 0) { + return attrB(buf, defaultCode, first); + } + if ((colorValue & rgbFlag) != 0) { + int r = (int) (colorValue >> (colorExp + 16)) & 0xFF; + int g = (int) (colorValue >> (colorExp + 8)) & 0xFF; + int b = (int) (colorValue >> colorExp) & 0xFF; + if (colors >= HIGH_COLORS) { + return attrRgbB(buf, csiPrefix, r, g, b, first); + } + return appendRoundedColorB( + buf, palette.round(r, g, b), csiPrefix, lowBase, highBase, colors, force, palette, first); + } + if ((colorValue & indFlag) != 0) { + int rounded = palette.round((int) (colorValue >> colorExp) & 0xFF); + return appendRoundedColorB(buf, rounded, csiPrefix, lowBase, highBase, colors, force, palette, first); + } + return first; + } + + private static boolean appendRoundedColorB( + ByteArrayBuilder buf, + int rounded, + int csiPrefix, + int lowBase, + int highBase, + int colors, + ForceMode force, + ColorPalette palette, + boolean first) { + if (rounded < 0) { + return first; + } + if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { + int col = palette.getColor(rounded); + return attrRgbB(buf, csiPrefix, (col >> 16) & 0xFF, (col >> 8) & 0xFF, col & 0xFF, first); + } + if (force == ForceMode.Force256Colors || rounded >= 16) { + return attrIdxB(buf, csiPrefix, rounded, first); + } + if (rounded >= 8) { + return attrIntB(buf, highBase + rounded - 8, first); + } + return attrIntB(buf, lowBase + rounded, first); + } + + private static boolean usedBasicFgColor(long fg, int colors, ForceMode force, ColorPalette palette) { + int rounded; + if ((fg & F_FOREGROUND_RGB) != 0) { + if (colors >= HIGH_COLORS) { + return false; + } + int r = (int) (fg >> (FG_COLOR_EXP + 16)) & 0xFF; + int g = (int) (fg >> (FG_COLOR_EXP + 8)) & 0xFF; + int b = (int) (fg >> FG_COLOR_EXP) & 0xFF; + rounded = palette.round(r, g, b); + } else if ((fg & F_FOREGROUND_IND) != 0) { + rounded = palette.round((int) (fg >> FG_COLOR_EXP) & 0xFF); + } else { + return false; + } + return rounded >= 0 + && rounded < 16 + && !(colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) + && force != ForceMode.Force256Colors; + } + + private static boolean appendBoldFaintB(ByteArrayBuilder buf, long d, long s, boolean first) { + if ((d & (F_BOLD | F_FAINT)) != 0) { + if ((d & F_BOLD) != 0 && (s & F_BOLD) == 0 || (d & F_FAINT) != 0 && (s & F_FAINT) == 0) { + first = attrB(buf, "22", first); + } + if ((d & F_BOLD) != 0 && (s & F_BOLD) != 0) { + first = attrB(buf, "1", first); + } + if ((d & F_FAINT) != 0 && (s & F_FAINT) != 0) { + first = attrB(buf, "2", first); + } + } + return first; + } + // @spotless:off /** * Substitutes box-drawing characters with alternate charset or ASCII equivalents. diff --git a/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java index ec18c988e..dfa2c5d96 100644 --- a/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java +++ b/terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java @@ -17,31 +17,31 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -public class ByteArrayBuilderTest { +class ByteArrayBuilderTest { @Test - public void testAppendAsciiChar() { + void testAppendAsciiChar() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendAscii('A').appendAscii('B').appendAscii('C'); assertEquals("ABC", buf.toStringUtf8()); } @Test - public void testAppendAsciiString() { + void testAppendAsciiString() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendAscii("hello world"); assertEquals("hello world", buf.toStringUtf8()); } @Test - public void testCsi() { + void testCsi() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.csi().appendAscii("0m"); assertEquals("\033[0m", buf.toStringUtf8()); } @Test - public void testAppendIntSmallValues() { + void testAppendIntSmallValues() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendInt(0); assertEquals("0", buf.toStringUtf8()); @@ -60,7 +60,7 @@ public void testAppendIntSmallValues() { } @Test - public void testAppendIntLargeValues() { + void testAppendIntLargeValues() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendInt(1000); assertEquals("1000", buf.toStringUtf8()); @@ -71,7 +71,7 @@ public void testAppendIntLargeValues() { } @Test - public void testAppendIntNegative() { + void testAppendIntNegative() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendInt(-1); assertEquals("-1", buf.toStringUtf8()); @@ -82,21 +82,21 @@ public void testAppendIntNegative() { } @Test - public void testAppendIntMinValue() { + void testAppendIntMinValue() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendInt(Integer.MIN_VALUE); assertEquals(Integer.toString(Integer.MIN_VALUE), buf.toStringUtf8()); } @Test - public void testAppendUtf8Ascii() { + void testAppendUtf8Ascii() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendUtf8('A'); assertArrayEquals(new byte[] {'A'}, buf.toByteArray()); } @Test - public void testAppendUtf8TwoByte() { + void testAppendUtf8TwoByte() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendUtf8('\u00E9'); // é assertEquals("é", buf.toStringUtf8()); @@ -104,7 +104,7 @@ public void testAppendUtf8TwoByte() { } @Test - public void testAppendUtf8ThreeByte() { + void testAppendUtf8ThreeByte() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendUtf8('\u4E16'); // 世 assertEquals("世", buf.toStringUtf8()); @@ -112,7 +112,7 @@ public void testAppendUtf8ThreeByte() { } @Test - public void testAppendUtf8SupplementaryCodePoint() { + void testAppendUtf8SupplementaryCodePoint() { ByteArrayBuilder buf = new ByteArrayBuilder(); int cp = 0x1F600; // 😀 buf.appendUtf8(cp); @@ -122,7 +122,7 @@ public void testAppendUtf8SupplementaryCodePoint() { } @Test - public void testAnsiColorSequence() { + void testAnsiColorSequence() { ByteArrayBuilder buf = new ByteArrayBuilder(); // Build: \033[38;2;128;64;255m buf.csi() @@ -138,7 +138,7 @@ public void testAnsiColorSequence() { } @Test - public void testReset() { + void testReset() { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendAscii("first"); assertEquals(5, buf.length()); @@ -149,7 +149,7 @@ public void testReset() { } @Test - public void testWriteTo() throws IOException { + void testWriteTo() throws IOException { ByteArrayBuilder buf = new ByteArrayBuilder(); buf.appendAscii("test"); ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -158,7 +158,7 @@ public void testWriteTo() throws IOException { } @Test - public void testGrowth() { + void testGrowth() { ByteArrayBuilder buf = new ByteArrayBuilder(4); StringBuilder expected = new StringBuilder(); for (int i = 0; i < 1000; i++) { @@ -170,7 +170,7 @@ public void testGrowth() { } @Test - public void testAsAsciiAppendable() throws IOException { + void testAsAsciiAppendable() throws IOException { ByteArrayBuilder buf = new ByteArrayBuilder(); Appendable app = buf.asAsciiAppendable(); app.append('A'); @@ -180,7 +180,7 @@ public void testAsAsciiAppendable() throws IOException { } @Test - public void testToAnsiBytesParity() { + void testToAnsiBytesParity() { // Verify that toAnsiBytes produces the same output as toAnsi AttributedStringBuilder asb = new AttributedStringBuilder(); asb.style(AttributedStyle.DEFAULT.foreground(128, 64, 255)); @@ -202,7 +202,7 @@ public void testToAnsiBytesParity() { } @Test - public void testToAnsiBytesParityWithIndexedColors() { + void testToAnsiBytesParityWithIndexedColors() { AttributedStringBuilder asb = new AttributedStringBuilder(); asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)); asb.append("red"); @@ -222,7 +222,7 @@ public void testToAnsiBytesParityWithIndexedColors() { } @Test - public void testToAnsiBytesWithMultiByteChars() { + void testToAnsiBytesWithMultiByteChars() { AttributedStringBuilder asb = new AttributedStringBuilder(); asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN)); asb.append("hello 世界"); From a6423223b720e6b14e0d9977a883b44a3a7b2259 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 13:55:31 +0200 Subject: [PATCH 4/7] fix: address remaining SonarCloud issues - Reduce appendDecorationAttrsB cognitive complexity using table-driven loop (S3776) - Reduce appendColorB params from 13 to 7 using boolean isForeground (S107) - Reduce appendRoundedColorB params from 9 to 7 using boolean isForeground (S107) --- .../jline/utils/AttributedCharSequence.java | 84 ++++++------------- 1 file changed, 24 insertions(+), 60 deletions(-) diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index ea889c444..2e71eaa2c 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -337,64 +337,31 @@ private static void emitStyleChange( boolean first = true; first = appendDecorationAttrsB(buf, d, newStyle, first); if (colorState[0] != fg) { - first = appendColorB( - buf, - fg, - F_FOREGROUND_RGB, - F_FOREGROUND_IND, - FG_COLOR_EXP, - 38, - 30, - 90, - "39", - colors, - force, - palette, - first); + first = appendColorB(buf, fg, true, colors, force, palette, first); if (fg > 0 && usedBasicFgColor(fg, colors, force, palette)) { d |= (newStyle & F_BOLD); } colorState[0] = fg; } if (colorState[1] != bg) { - first = appendColorB( - buf, - bg, - F_BACKGROUND_RGB, - F_BACKGROUND_IND, - BG_COLOR_EXP, - 48, - 40, - 100, - "49", - colors, - force, - palette, - first); + first = appendColorB(buf, bg, false, colors, force, palette, first); colorState[1] = bg; } first = appendBoldFaintB(buf, d, newStyle, first); buf.appendAscii('m'); } + private static final long[] DECORATION_FLAGS = {F_ITALIC, F_UNDERLINE, F_BLINK, F_INVERSE, F_CONCEAL, F_CROSSED_OUT + }; + private static final String[] DECORATION_ON = {"3", "4", "5", "7", "8", "9"}; + private static final String[] DECORATION_OFF = {"23", "24", "25", "27", "28", "29"}; + private static boolean appendDecorationAttrsB(ByteArrayBuilder buf, long d, long s, boolean first) { - if ((d & F_ITALIC) != 0) { - first = attrB(buf, (s & F_ITALIC) != 0 ? "3" : "23", first); - } - if ((d & F_UNDERLINE) != 0) { - first = attrB(buf, (s & F_UNDERLINE) != 0 ? "4" : "24", first); - } - if ((d & F_BLINK) != 0) { - first = attrB(buf, (s & F_BLINK) != 0 ? "5" : "25", first); - } - if ((d & F_INVERSE) != 0) { - first = attrB(buf, (s & F_INVERSE) != 0 ? "7" : "27", first); - } - if ((d & F_CONCEAL) != 0) { - first = attrB(buf, (s & F_CONCEAL) != 0 ? "8" : "28", first); - } - if ((d & F_CROSSED_OUT) != 0) { - first = attrB(buf, (s & F_CROSSED_OUT) != 0 ? "9" : "29", first); + for (int i = 0; i < DECORATION_FLAGS.length; i++) { + long flag = DECORATION_FLAGS[i]; + if ((d & flag) != 0) { + first = attrB(buf, (s & flag) != 0 ? DECORATION_ON[i] : DECORATION_OFF[i], first); + } } return first; } @@ -402,19 +369,17 @@ private static boolean appendDecorationAttrsB(ByteArrayBuilder buf, long d, long private static boolean appendColorB( ByteArrayBuilder buf, long colorValue, - long rgbFlag, - long indFlag, - int colorExp, - int csiPrefix, - int lowBase, - int highBase, - String defaultCode, + boolean isForeground, int colors, ForceMode force, ColorPalette palette, boolean first) { + long rgbFlag = isForeground ? F_FOREGROUND_RGB : F_BACKGROUND_RGB; + long indFlag = isForeground ? F_FOREGROUND_IND : F_BACKGROUND_IND; + int colorExp = isForeground ? FG_COLOR_EXP : BG_COLOR_EXP; + int csiPrefix = isForeground ? 38 : 48; if (colorValue <= 0) { - return attrB(buf, defaultCode, first); + return attrB(buf, isForeground ? "39" : "49", first); } if ((colorValue & rgbFlag) != 0) { int r = (int) (colorValue >> (colorExp + 16)) & 0xFF; @@ -423,12 +388,11 @@ private static boolean appendColorB( if (colors >= HIGH_COLORS) { return attrRgbB(buf, csiPrefix, r, g, b, first); } - return appendRoundedColorB( - buf, palette.round(r, g, b), csiPrefix, lowBase, highBase, colors, force, palette, first); + return appendRoundedColorB(buf, palette.round(r, g, b), isForeground, colors, force, palette, first); } if ((colorValue & indFlag) != 0) { int rounded = palette.round((int) (colorValue >> colorExp) & 0xFF); - return appendRoundedColorB(buf, rounded, csiPrefix, lowBase, highBase, colors, force, palette, first); + return appendRoundedColorB(buf, rounded, isForeground, colors, force, palette, first); } return first; } @@ -436,13 +400,12 @@ private static boolean appendColorB( private static boolean appendRoundedColorB( ByteArrayBuilder buf, int rounded, - int csiPrefix, - int lowBase, - int highBase, + boolean isForeground, int colors, ForceMode force, ColorPalette palette, boolean first) { + int csiPrefix = isForeground ? 38 : 48; if (rounded < 0) { return first; } @@ -453,8 +416,9 @@ private static boolean appendRoundedColorB( if (force == ForceMode.Force256Colors || rounded >= 16) { return attrIdxB(buf, csiPrefix, rounded, first); } + int lowBase = isForeground ? 30 : 40; if (rounded >= 8) { - return attrIntB(buf, highBase + rounded - 8, first); + return attrIntB(buf, (isForeground ? 90 : 100) + rounded - 8, first); } return attrIntB(buf, lowBase + rounded, first); } From b3fc169f6bd173e444e0930d31aade28dc54b0c2 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 15:23:14 +0200 Subject: [PATCH 5/7] fix: handle null in AsciiAppendable and overflow in ensureCapacity - AsciiAppendable.append(CharSequence) and append(CharSequence, int, int) now treat null as "null" per the Appendable contract - ensureCapacity uses long arithmetic to avoid overflow when doubling --- .../src/main/java/org/jline/utils/ByteArrayBuilder.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java index 107eb7006..1d32b5c6d 100644 --- a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java +++ b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java @@ -44,7 +44,8 @@ public ByteArrayBuilder(int initialCapacity) { private void ensureCapacity(int minCapacity) { if (minCapacity > buffer.length) { - int newCapacity = Math.max(buffer.length << 1, minCapacity); + long doubleCap = (long) buffer.length * 2; + int newCapacity = (int) Math.max(Math.min(doubleCap, Integer.MAX_VALUE), minCapacity); byte[] newBuffer = new byte[newCapacity]; System.arraycopy(buffer, 0, newBuffer, 0, count); buffer = newBuffer; @@ -220,6 +221,9 @@ public Appendable asAsciiAppendable() { private class AsciiAppendable implements Appendable { @Override public Appendable append(CharSequence csq) { + if (csq == null) { + csq = "null"; + } int len = csq.length(); ensureCapacity(count + len); for (int i = 0; i < len; i++) { @@ -230,6 +234,9 @@ public Appendable append(CharSequence csq) { @Override public Appendable append(CharSequence csq, int start, int end) { + if (csq == null) { + csq = "null"; + } int len = end - start; ensureCapacity(count + len); for (int i = start; i < end; i++) { From be96f0d102ae9eb847f706ec2312b022c593779a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:24:04 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`opt?= =?UTF-8?q?imize-byte-buffer-rendering`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @gnodet. The following files were modified: * `terminal/src/main/java/org/jline/utils/AttributedCharSequence.java` * `terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java` * `terminal/src/main/java/org/jline/utils/Display.java` These files were ignored: * `terminal/src/test/java/org/jline/utils/ByteArrayBuilderTest.java` --- .../jline/utils/AttributedCharSequence.java | 204 +++++++++++++++--- .../org/jline/utils/ByteArrayBuilder.java | 109 ++++++++-- .../main/java/org/jline/utils/Display.java | 74 ++++++- 3 files changed, 334 insertions(+), 53 deletions(-) diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index 2e71eaa2c..39110c411 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -226,22 +226,15 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette) { } /** - * Converts this attributed string to an ANSI escape sequence string - * with the specified color capabilities, force mode, color palette, - * and alternate character set sequences. + * Render this attributed string as an ANSI escape sequence string using the provided + * color capabilities and alternate character set sequences. * - *

- * This method renders the attributed string with ANSI escape sequences - * using the specified number of colors, force mode, color palette, and - * alternate character set sequences for box drawing characters. - *

- * - * @param colors the number of colors to use (8, 256, or 16777216 for true colors) - * @param force the force mode to use for color rendering - * @param palette the color palette to use for color conversion, or null for the default palette - * @param altIn the sequence to enable the alternate character set, or null to disable - * @param altOut the sequence to disable the alternate character set, or null to disable - * @return a string with ANSI escape sequences representing this attributed string + * @param colors the number of colors to use (commonly 8, 256, or 16777216 for true color) + * @param force the force mode controlling whether 256-color or true-color forms are preferred + * @param palette the color palette used to map colors, or {@code null} to use the default palette + * @param altIn the sequence to enable the alternate character set for box-drawing, or {@code null} to disable + * @param altOut the sequence to disable the alternate character set, or {@code null} to disable + * @return the ANSI-encoded representation of this attributed string */ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) { ByteArrayBuilder buf = new ByteArrayBuilder(); @@ -250,19 +243,20 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette, String a } /** - * Writes the ANSI rendering of this attributed string directly as UTF-8 bytes - * to the given {@link ByteArrayBuilder}, avoiding intermediate String allocations. + * Write the ANSI-encoded UTF-8 bytes for this attributed string into the provided buffer. * - *

This method produces output equivalent to - * {@link #toAnsi(int, ForceMode, ColorPalette, String, String)} but writes bytes - * directly, eliminating StringBuilder, Integer.toString(), and charset encoding overhead.

+ *

The method encodes styles, colors, decoration attributes, and alternate-charset + * box-drawing sequences as ANSI control sequences and appends their UTF-8 bytes to + * {@code buf}. If {@code palette} is null, the default palette is used. The method + * ensures any active alternate-charset is exited and final style state is reset + * before returning.

* - * @param buf the byte buffer to write to - * @param colors the number of colors to use - * @param force the force mode for color rendering - * @param palette the color palette, or null for default - * @param altIn the alternate charset enter sequence, or null - * @param altOut the alternate charset exit sequence, or null + * @param buf the byte buffer to write UTF-8 bytes and ANSI control sequences to + * @param colors the number of displayable colors to target when emitting color sequences + * @param force the force mode that influences whether truecolor/256-color forms are used + * @param palette the color palette to use for rounding/indexing, or null to use the default + * @param altIn the sequence to enter the terminal's alternate character set, or null + * @param altOut the sequence to exit the terminal's alternate character set, or null */ void toAnsiBytes( ByteArrayBuilder buf, int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) { @@ -292,6 +286,17 @@ void toAnsiBytes( } } + /** + * Toggle and emit the terminal alternate character set sequence when encountering + * box-drawing characters. + * + * @param buf buffer to which enter/exit alternate-char sequences are appended + * @param originalChar the character being examined for box-drawing status + * @param alt whether the alternate character set is currently active + * @param altIn sequence to enable alternate character set (may be null) + * @param altOut sequence to disable alternate character set (may be null) + * @return `true` if the alternate character set should be active after processing `originalChar`, `false` otherwise + */ private static boolean emitAltCharset( ByteArrayBuilder buf, char originalChar, boolean alt, String altIn, String altOut) { if (altIn != null && altOut != null) { @@ -304,6 +309,18 @@ private static boolean emitAltCharset( return alt; } + /** + * Appends the UTF-8 encoding of the character (or surrogate pair) at position `i` to the buffer. + * + * If `c` is a UTF-16 high-surrogate and the following code unit (within `len`) is a low-surrogate, + * the combined code point is appended. + * + * @param buf the byte buffer to append UTF-8 bytes into + * @param c the character at index `i` + * @param i the index within the sequence corresponding to `c` + * @param len the sequence length (used to ensure the low-surrogate is within bounds) + * @return 2 if a surrogate pair was consumed and appended, otherwise 1 + */ private int emitUtf8Char(ByteArrayBuilder buf, char c, int i, int len) { if (Character.isHighSurrogate(c) && i + 1 < len) { char next = charAt(i + 1); @@ -316,6 +333,22 @@ private int emitUtf8Char(ByteArrayBuilder buf, char c, int i, int len) { return 1; } + /** + * Emit a single CSI SGR sequence that transitions terminal attributes from prevStyle to newStyle. + * + * Writes ANSI SGR parameters into the provided ByteArrayBuilder and updates colorState to reflect the + * currently applied foreground (index 0) and background (index 1) encodings. If newStyle is zero, a + * reset sequence is emitted and colorState entries are cleared. + * + * @param buf the byte-oriented builder to which CSI parameters and the final 'm' are appended + * @param prevStyle previously applied style code + * @param newStyle target style code to apply + * @param colorState two-element array tracking currently applied foreground (0) and background (1); + * this method mutates its entries to the values actually emitted + * @param colors terminal maximum color capability (affects truecolor/256-color selection) + * @param force force mode controlling preference for 256/true color output + * @param palette color palette used to round/lookup colors when not emitting direct RGB + */ private static void emitStyleChange( ByteArrayBuilder buf, long prevStyle, @@ -356,6 +389,17 @@ private static void emitStyleChange( private static final String[] DECORATION_ON = {"3", "4", "5", "7", "8", "9"}; private static final String[] DECORATION_OFF = {"23", "24", "25", "27", "28", "29"}; + /** + * Appends CSI decoration parameters for each decoration flag present in `d` to `buf`, using the + * enabled/disabled codes from `s`. + * + * @param buf the byte buffer receiving CSI parameters + * @param d bitmask of decoration flags that have changed and should be emitted + * @param s current style bitmask used to choose the ON or OFF code for each flag + * @param first true if no CSI parameters have yet been written (affects separator emission) + * @return `true` if no parameters were appended (so subsequent parameter should not be prefixed), + * `false` if at least one parameter was appended (so subsequent parameter should be prefixed) + */ private static boolean appendDecorationAttrsB(ByteArrayBuilder buf, long d, long s, boolean first) { for (int i = 0; i < DECORATION_FLAGS.length; i++) { long flag = DECORATION_FLAGS[i]; @@ -366,6 +410,22 @@ private static boolean appendDecorationAttrsB(ByteArrayBuilder buf, long d, long return first; } + /** + * Appends CSI color parameters for a foreground or background color value to the byte buffer. + * + *

Handles three color encodings encoded in {@code colorValue}: + * - default (<= 0) emits the reset parameter (39 for foreground, 49 for background), + * - RGB (flag present) emits a truecolor parameter when supported or delegates to palette rounding, + * - indexed (flag present) rounds the index via {@code palette} and emits an appropriate form. + * + * @param buf the byte-oriented builder to receive CSI parameters + * @param colorValue encoded color value containing flags and components (RGB or indexed) + * @param isForeground true when emitting a foreground color, false for background + * @param colors current terminal color capability (used to choose truecolor vs palette/indexed forms) + * @param force color forcing mode that may override form selection + * @param palette palette used to round RGB or indexed values into a terminal index + * @param first true if this is the first CSI parameter (no leading separator); updated based on what is emitted + * @return true if no parameter was appended (the "first" state remains), false if a parameter was appended */ private static boolean appendColorB( ByteArrayBuilder buf, long colorValue, @@ -397,6 +457,19 @@ private static boolean appendColorB( return first; } + /** + * Append the appropriate CSI color parameter (truecolor RGB, 256-color index, or basic ANSI color) + * for a rounded palette entry to the given buffer. + * + * @param buf the byte buffer receiving CSI parameters + * @param rounded the palette index for the desired color, or a negative value to indicate no color + * @param isForeground true to emit a foreground color parameter, false for background + * @param colors the terminal's reported color capacity (numeric) + * @param force a ForceMode hint that can force 256- or true-color emission + * @param palette the ColorPalette used to resolve truecolor values when required + * @param first whether this is the first CSI parameter (affects whether a leading separator is emitted) + * @return `true` if no separator was emitted (i.e., still first), `false` otherwise. + */ private static boolean appendRoundedColorB( ByteArrayBuilder buf, int rounded, @@ -423,6 +496,20 @@ private static boolean appendRoundedColorB( return attrIntB(buf, lowBase + rounded, first); } + /** + * Determines whether the encoded foreground color should be rendered using a basic (0–15) terminal color. + * + *

For an RGB-encoded or indexed foreground value in `fg`, returns `true` when the palette rounds it to an + * index in the 0–15 range and the current `colors`/`force` configuration does not require forcing 256-color + * or truecolor output; otherwise returns `false`.

+ * + * @param fg encoded foreground style bits (may contain RGB or indexed color encoding) + * @param colors terminal reported color capacity (used to decide truecolor behavior) + * @param force color forcing mode that may require 256-color or truecolor output + * @param palette palette used to round RGB or indexed values to a terminal color index + * @return `true` if the foreground maps to a basic terminal color (0–15) and basic-color emission is allowed; + * `false` otherwise. + */ private static boolean usedBasicFgColor(long fg, int colors, ForceMode force, ColorPalette palette) { int rounded; if ((fg & F_FOREGROUND_RGB) != 0) { @@ -444,6 +531,18 @@ private static boolean usedBasicFgColor(long fg, int colors, ForceMode force, Co && force != ForceMode.Force256Colors; } + /** + * Appends appropriate SGR parameters for bold and faint transitions to the buffer when those + * attributes changed, and updates the CSI parameter separation state. + * + * @param buf the byte buffer used to build the CSI sequence + * @param d bitmask of attributes that changed (diff between previous and new style) + * @param s the current style bitmask (after change) + * @param first true if no CSI parameter has yet been emitted for this sequence; used to decide + * whether to prepend a separator + * @return the updated `first` flag indicating whether subsequent parameters need a separator + * (`true` if still first, `false` if a parameter was emitted) + */ private static boolean appendBoldFaintB(ByteArrayBuilder buf, long d, long s, boolean first) { if ((d & (F_BOLD | F_FAINT)) != 0) { if ((d & F_BOLD) != 0 && (s & F_BOLD) == 0 || (d & F_FAINT) != 0 && (s & F_FAINT) == 0) { @@ -461,7 +560,12 @@ private static boolean appendBoldFaintB(ByteArrayBuilder buf, long d, long s, bo // @spotless:off /** - * Substitutes box-drawing characters with alternate charset or ASCII equivalents. + * Map box-drawing characters to alternate-charset codes when alternate sequences are available, otherwise to simple ASCII equivalents. + * + * @param c the input character to substitute + * @param altIn the terminal's enter-alternate-charset sequence, or null if not available + * @param altOut the terminal's exit-alternate-charset sequence, or null if not available + * @return the substituted character (an alternate-charset code or an ASCII fallback), or the original character if no substitution applies */ private static char substituteChar(char c, String altIn, String altOut) { if (altIn != null && altOut != null) { @@ -491,6 +595,12 @@ private static char substituteChar(char c, String altIn, String altOut) { } } + /** + * Determines whether the given character is one of the supported Unicode box-drawing characters. + * + * @param c the character to test + * @return true if the character is a box-drawing glyph handled by this class, false otherwise + */ private static boolean isBoxDrawing(char c) { switch (c) { case '┘': case '┐': case '┌': case '└': @@ -503,19 +613,47 @@ private static boolean isBoxDrawing(char c) { } // @spotless:on - // ByteArrayBuilder helpers — zero-allocation integer formatting + /** + * Append a CSI parameter string to the byte buffer, prefixing with ';' when not the first parameter. + * + * @param buf destination ByteArrayBuilder to append ASCII bytes to + * @param s parameter string to append (ASCII) + * @param first whether this is the first parameter in the CSI sequence + * @return `false` to indicate subsequent parameters must be separated by ';' + */ private static boolean attrB(ByteArrayBuilder buf, String s, boolean first) { if (!first) buf.appendAscii(';'); buf.appendAscii(s); return false; } + /** + * Append an integer parameter to the byte buffer, prefixing it with ';' when it is not the first parameter. + * + * @param buf the byte buffer to append into + * @param value the integer value to append as a parameter + * @param first true if this is the first CSI parameter (no leading ';'), false otherwise + * @return `false` to indicate subsequent parameters are not the first + */ private static boolean attrIntB(ByteArrayBuilder buf, int value, boolean first) { if (!first) buf.appendAscii(';'); buf.appendInt(value); return false; } + /** + * Append an RGB color parameter sequence to the provided ByteArrayBuilder for a CSI sequence. + * + * Appends the form "{prefix};2;{r};{g};{b}" and writes a leading ';' if `first` is false. + * + * @param buf the byte-oriented builder to append CSI parameters to + * @param prefix CSI color prefix (typically 38 for foreground or 48 for background) + * @param r red component (0–255) + * @param g green component (0–255) + * @param b blue component (0–255) + * @param first true when this is the first CSI parameter (omit leading ';'); false otherwise + * @return `false` indicating subsequent parameters are not the first + */ private static boolean attrRgbB(ByteArrayBuilder buf, int prefix, int r, int g, int b, boolean first) { if (!first) buf.appendAscii(';'); buf.appendInt(prefix) @@ -528,6 +666,16 @@ private static boolean attrRgbB(ByteArrayBuilder buf, int prefix, int r, int g, return false; } + /** + * Appends a CSI indexed-color parameter of the form `prefix;5;idx` to the byte buffer, + * inserting a leading `;` only if this is not the first parameter. + * + * @param buf the byte buffer to append into + * @param prefix the CSI color prefix (commonly `38` for foreground or `48` for background) + * @param idx the palette index to emit + * @param first true if this is the first CSI parameter (omits a leading `;`), false otherwise + * @return false (marks that subsequent parameters are no longer the first) + */ private static boolean attrIdxB(ByteArrayBuilder buf, int prefix, int idx, boolean first) { if (!first) buf.appendAscii(';'); buf.appendInt(prefix).appendAscii(";5;").appendInt(idx); diff --git a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java index 1d32b5c6d..60bc69b2a 100644 --- a/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java +++ b/terminal/src/main/java/org/jline/utils/ByteArrayBuilder.java @@ -34,14 +34,31 @@ public class ByteArrayBuilder { private byte[] buffer; private int count; + /** + * Creates a new ByteArrayBuilder with a default initial capacity of 256 bytes. + */ public ByteArrayBuilder() { this(256); } + /** + * Creates a ByteArrayBuilder with the specified initial capacity. + * + * @param initialCapacity the initial size of the internal byte buffer in bytes + */ public ByteArrayBuilder(int initialCapacity) { buffer = new byte[initialCapacity]; } + /** + * Ensures the internal byte buffer has length at least {@code minCapacity}, growing it if necessary. + * + * If the current buffer is too small, allocates a larger array (at least {@code minCapacity}), + * copies existing bytes, and replaces the buffer. Growth attempts to double the current size, + * capped to {@code Integer.MAX_VALUE}. + * + * @param minCapacity minimum required capacity for the internal buffer + */ private void ensureCapacity(int minCapacity) { if (minCapacity > buffer.length) { long doubleCap = (long) buffer.length * 2; @@ -53,7 +70,9 @@ private void ensureCapacity(int minCapacity) { } /** - * Appends a CSI (Control Sequence Introducer) escape: ESC [ + * Appends the CSI (Control Sequence Introducer) two-byte sequence ESC '[' to the buffer. + * + * @return this ByteArrayBuilder instance for call chaining */ public ByteArrayBuilder csi() { ensureCapacity(count + 2); @@ -63,7 +82,10 @@ public ByteArrayBuilder csi() { } /** - * Appends a single ASCII character as a byte. + * Appends the given ASCII character as a single byte to the builder. + * + * @param c the ASCII character to append (expected in range U+0000..U+007F) + * @return this ByteArrayBuilder */ public ByteArrayBuilder appendAscii(char c) { ensureCapacity(count + 1); @@ -72,8 +94,10 @@ public ByteArrayBuilder appendAscii(char c) { } /** - * Appends an ASCII string directly as bytes, bypassing charset encoding. - * The string must contain only ASCII characters (0x00-0x7F). + * Appends the characters of the given string as raw ASCII bytes to the builder without charset encoding. + * + * @param s the ASCII string to append; each character must be in the range 0x00–0x7F + * @return this ByteArrayBuilder */ public ByteArrayBuilder appendAscii(String s) { int len = s.length(); @@ -85,8 +109,13 @@ public ByteArrayBuilder appendAscii(String s) { } /** - * Appends an integer as ASCII digit bytes without creating a String. - * Optimized for the common case of small values (0-999) used in ANSI color codes. + * Appends the decimal ASCII representation of an integer to the buffer. + * + *

Negative values are prefixed with `'-'`. `Integer.MIN_VALUE` and values + * outside the common small range are appended via a `String` fallback. + * + * @param value the integer to append as ASCII digits + * @return this builder instance for method chaining */ public ByteArrayBuilder appendInt(int value) { if (value < 0) { @@ -116,8 +145,10 @@ public ByteArrayBuilder appendInt(int value) { } /** - * Appends a character as UTF-8 encoded bytes. - * Fast-paths ASCII characters (single byte). + * Appends the given character to the builder by encoding it as UTF-8 bytes. + * + * @param c the character to encode and append + * @return this ByteArrayBuilder instance */ public ByteArrayBuilder appendUtf8(char c) { if (c < 0x80) { @@ -137,8 +168,10 @@ public ByteArrayBuilder appendUtf8(char c) { } /** - * Appends a Unicode code point as UTF-8 encoded bytes. - * Handles supplementary characters (code points above U+FFFF). + * Append the given Unicode code point encoded as UTF-8 bytes. + * + * @param codePoint the Unicode code point to append (U+0000..U+10FFFF) + * @return the builder instance for chaining */ public ByteArrayBuilder appendUtf8(int codePoint) { if (codePoint < 0x80) { @@ -171,7 +204,9 @@ public byte[] buffer() { } /** - * Returns the number of bytes written to the buffer. + * Reports the number of bytes written to the buffer. + * + * @return the number of valid bytes stored in the internal buffer */ public int length() { return count; @@ -185,7 +220,9 @@ public void reset() { } /** - * Returns a copy of the buffer contents as a byte array. + * Create a new byte array containing the bytes written to the builder. + * + * @return a newly allocated byte array with the builder's contents (bytes at indices 0..length()-1) */ public byte[] toByteArray() { byte[] result = new byte[count]; @@ -194,7 +231,13 @@ public byte[] toByteArray() { } /** - * Writes the buffer contents to an output stream. + * Writes the valid bytes stored in this builder to the given output stream. + * + * Writes the bytes in the internal buffer from index 0 (inclusive) to {@link #length()} (exclusive). + * If the builder is empty, this method does nothing. + * + * @param out the OutputStream to which the bytes will be written + * @throws IOException if an I/O error occurs while writing to the stream */ public void writeTo(OutputStream out) throws IOException { if (count > 0) { @@ -203,22 +246,37 @@ public void writeTo(OutputStream out) throws IOException { } /** - * Returns the buffer contents as a UTF-8 string. + * Convert the builder's written bytes to a UTF-8 String. + * + * @return the bytes from index 0 to length()-1 decoded using UTF-8 */ public String toStringUtf8() { return new String(buffer, 0, count, StandardCharsets.UTF_8); } /** - * Returns an {@link Appendable} view that writes ASCII characters to this builder. - * Suitable for use with {@link Curses#tputs(Appendable, String, Object...)} since - * terminal capability sequences are pure ASCII. + * Provides an Appendable that writes ASCII characters into this builder. + * + * The returned Appendable encodes each character as a single ASCII byte and appends it + * to this ByteArrayBuilder, making it suitable for Appendable-based APIs that emit + * terminal capability sequences. + * + * @return an Appendable whose append methods write characters as ASCII bytes into this builder */ public Appendable asAsciiAppendable() { return new AsciiAppendable(); } private class AsciiAppendable implements Appendable { + /** + * Appends the characters of a CharSequence to the enclosing ByteArrayBuilder as ASCII bytes. + * + * If {@code csq} is {@code null}, the four characters "null" are appended. Each character is written + * by casting to a single byte (low 8 bits). + * + * @param csq the character sequence to append, or {@code null} + * @return this Appendable + */ @Override public Appendable append(CharSequence csq) { if (csq == null) { @@ -232,6 +290,17 @@ public Appendable append(CharSequence csq) { return this; } + /** + * Appends the subsequence [start, end) of the given CharSequence as ASCII bytes. + * + * If {@code csq} is {@code null}, the four-character sequence {@code "null"} is appended. + * Each character is cast to a single byte and written into the enclosing ByteArrayBuilder. + * + * @param csq the character sequence to append, or {@code null} + * @param start start index, inclusive + * @param end end index, exclusive + * @return this Appendable + */ @Override public Appendable append(CharSequence csq, int start, int end) { if (csq == null) { @@ -245,6 +314,12 @@ public Appendable append(CharSequence csq, int start, int end) { return this; } + /** + * Appends a single character to the builder as an ASCII byte. + * + * @param c the character to append; its low-order 8 bits are written as a single byte + * @return this Appendable instance + */ @Override public Appendable append(char c) { ensureCapacity(count + 1); diff --git a/terminal/src/main/java/org/jline/utils/Display.java b/terminal/src/main/java/org/jline/utils/Display.java index df6862a9c..961e612ec 100644 --- a/terminal/src/main/java/org/jline/utils/Display.java +++ b/terminal/src/main/java/org/jline/utils/Display.java @@ -108,6 +108,16 @@ public class Display { private String ansiAltIn; private String ansiAltOut; + /** + * Create a Display bound to the given Terminal and configured for either full-screen + * or inline (partial-screen) usage. + * + *

Queries the terminal for capabilities and initializes internal flags that + * control scrolling, wrap-at-end-of-line behavior, and cursor movement semantics. + * + * @param terminal the target terminal used for rendering + * @param fullscreen true to enable full-screen (application-takes-over) mode, false for partial-screen mode + */ @SuppressWarnings("this-escape") public Display(Terminal terminal, boolean fullscreen) { this.terminal = terminal; @@ -475,6 +485,12 @@ public void update(List newLines, int targetCursorPos, boolean useByteMode = false; } + /** + * Emits terminal control sequences to delete the specified number of lines. + * + * @param nb the number of lines to delete + * @return `true` if a delete-line capability was available and the operation was issued, `false` otherwise + */ protected boolean deleteLines(int nb) { return perform(Capability.delete_line, Capability.parm_delete_line, nb); } @@ -495,6 +511,14 @@ protected boolean can(Capability single, Capability multi) { return terminal.getStringCapability(single) != null || terminal.getStringCapability(multi) != null; } + /** + * Emits a terminal capability to affect a repeated action, using the parameterized (multi) form when available and preferable, otherwise repeating the single-capability. + * + * @param single the single-invocation capability to use repeatedly if a multi-parameter form is unavailable or not preferable + * @param multi the multi-parameter capability that can perform the action for a specified count in one invocation + * @param nb the number of times the action should be applied + * @return {@code true} if a capability sequence was emitted to perform the action, {@code false} if neither capability is available + */ protected boolean perform(Capability single, Capability multi, int nb) { boolean hasMulti = terminal.getStringCapability(multi) != null; boolean hasSingle = terminal.getStringCapability(single) != null; @@ -564,12 +588,16 @@ protected void moveVisualCursorTo(int targetPos, List newLines } } - /* - * Move cursor from cursorPos to argument, updating cursorPos - * We're at the right margin if {@code (cursorPos % columns1) == columns}. - * This method knows how to move *from* the right margin, - * but does not know how to move *to* the right margin. - * I.e. {@code (i1 % columns1) == column} is not allowed. + /** + * Move the visual cursor to the specified wrapped-line position without allowing movement to a right-margin target. + * + * Moves the terminal cursor from the current visual position (stored in {@code cursorPos}) to {@code i1}, + * handling line and column transitions, and updating {@code cursorPos}. If the current position is at the right + * margin a carriage return is emitted before further movement. The target position must not lie on a right-margin + * column (i.e. {@code i1 % columns1 != columns}). + * + * @param i1 the target visual cursor position in wrapped-line coordinates + * @return the updated cursor position (equal to {@code i1}) */ protected int moveVisualCursorTo(int i1) { int i0 = cursorPos; @@ -613,12 +641,26 @@ protected int moveVisualCursorTo(int i1) { return i1; } + /** + * Prints the specified character to the terminal output the given number of times. + * + * @param c the character to print + * @param num the number of times to print {@code c}; if less than or equal to zero, nothing is printed + */ void rawPrint(char c, int num) { for (int i = 0; i < num; i++) { rawPrint(c); } } + /** + * Append or write a single Unicode code point to the terminal output buffer. + * + * If byte-mode is enabled, append the code point's UTF-8 bytes to the internal byte buffer; + * otherwise write the code point to the terminal's writer. + * + * @param c the Unicode code point to output + */ void rawPrint(int c) { if (useByteMode) { byteBuilder.appendUtf8(c); @@ -627,6 +669,13 @@ void rawPrint(int c) { } } + /** + * Writes the given attributed string to the display output. + * + * When byte-mode is enabled, the string is encoded as ANSI/UTF-8 bytes and appended to the internal output buffer; otherwise it is printed through the terminal's print path. + * + * @param str the attributed string to write (may contain styling/ANSI sequences) + */ void rawPrint(AttributedString str) { if (useByteMode) { str.toAnsiBytes(byteBuilder, ansiColors, ansiForceMode, ansiPalette, ansiAltIn, ansiAltOut); @@ -636,8 +685,11 @@ void rawPrint(AttributedString str) { } /** - * Writes a terminal capability sequence. In byte mode, writes directly to the - * byte buffer via Curses; otherwise delegates to terminal.puts(). + * Emit the terminal control sequence for the given capability, using the byte buffer when byte mode is enabled. + * + * @param capability the terminal capability to emit + * @param params parameters to format into the capability string, if applicable + * @return `true` if the capability sequence was emitted; `false` if the capability string is unavailable */ private boolean puts(Capability capability, Object... params) { if (useByteMode) { @@ -652,6 +704,12 @@ private boolean puts(Capability capability, Object... params) { } } + /** + * Compute the number of terminal columns required to display a string, interpreting ANSI escape sequences. + * + * @param str the input string, which may contain ANSI escape sequences; if `null` it is treated as empty + * @return the displayed column width of the string (0 if `str` is `null`) + */ public int wcwidth(String str) { return str != null ? AttributedString.fromAnsi(str).columnLength(terminal) : 0; } From 49073ae7fdf383f5671e79ff102f28ee81ca476a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 31 Mar 2026 23:50:56 +0200 Subject: [PATCH 7/7] docs: clarify ForceMode semantics and color rendering decision points ForceMode only affects how indexed/palette colors are rendered, not whether direct RGB values use truecolor encoding. Document this on the enum values and add inline comments at the two key branch points in appendColorB and appendRoundedColorB. --- .../jline/utils/AttributedCharSequence.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java index 39110c411..f7d1137ed 100644 --- a/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java +++ b/terminal/src/main/java/org/jline/utils/AttributedCharSequence.java @@ -78,26 +78,32 @@ public AttributedCharSequence() { private static final int HIGH_COLORS = 0x7FFF; /** - * Enum defining color mode forcing options for ANSI rendering. + * Controls how indexed (palette) colors are rendered in ANSI output. * - *

- * This enum specifies how color rendering should be forced when generating - * ANSI escape sequences, regardless of the terminal's reported capabilities. - *

+ *

These modes only affect colors that have been resolved to a palette index. + * Direct RGB colors (set via {@link AttributedStyle#foreground(int, int, int)}) + * are always emitted as {@code 38;2;r;g;b} when the terminal supports + * {@link #HIGH_COLORS}, regardless of this setting.

*/ public enum ForceMode { /** - * No forcing; use the terminal's reported color capabilities. + * No forcing; indexed colors are rendered using the best encoding for + * the terminal's reported color count (basic SGR 30-37/90-97, + * 256-color {@code 38;5;n}, or true-color {@code 38;2;r;g;b}). */ None, /** - * Force the use of 256-color mode (8-bit colors). + * Force indexed colors to use 256-color encoding ({@code 38;5;n}) + * even when a basic SGR code would suffice. */ Force256Colors, /** - * Force the use of true color mode (24-bit RGB colors). + * Force indexed colors to be expanded to true-color RGB + * ({@code 38;2;r;g;b}) via the palette, but only when the terminal + * supports {@link #HIGH_COLORS}. This does not override the color + * count check for direct RGB values. */ ForceTrueColors } @@ -445,6 +451,8 @@ private static boolean appendColorB( int r = (int) (colorValue >> (colorExp + 16)) & 0xFF; int g = (int) (colorValue >> (colorExp + 8)) & 0xFF; int b = (int) (colorValue >> colorExp) & 0xFF; + // Direct RGB: emit 38;2/48;2 only if terminal supports high colors; + // ForceMode does not override this — it only affects indexed colors if (colors >= HIGH_COLORS) { return attrRgbB(buf, csiPrefix, r, g, b, first); } @@ -482,10 +490,12 @@ private static boolean appendRoundedColorB( if (rounded < 0) { return first; } + // ForceTrueColors: expand indexed color to RGB via palette (requires high-color terminal) if (colors >= HIGH_COLORS && force == ForceMode.ForceTrueColors) { int col = palette.getColor(rounded); return attrRgbB(buf, csiPrefix, (col >> 16) & 0xFF, (col >> 8) & 0xFF, col & 0xFF, first); } + // Force256Colors: always use 38;5;n encoding; also used for extended palette (index >= 16) if (force == ForceMode.Force256Colors || rounded >= 16) { return attrIdxB(buf, csiPrefix, rounded, first); }