Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 114 additions & 50 deletions terminal/src/main/java/org/jline/utils/AttributedCharSequence.java
Original file line number Diff line number Diff line change
Expand Up @@ -245,52 +245,70 @@ public String toAnsi(int colors, ForceMode force, ColorPalette palette) {
*/
public String toAnsi(int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) {
ByteArrayBuilder buf = new ByteArrayBuilder();
toAnsiBytes(buf, colors, force, palette, altIn, altOut);
long[] state = {0, 0};
toAnsiBytes(buf, 0, length(), colors, force, palette, altIn, altOut, state);
if (state[1] != 0) {
buf.appendAscii(altOut);
}
if (state[0] != 0) {
buf.csi().appendAscii("0m");
}
return buf.toStringUtf8();
}

/**
* Write the ANSI-encoded UTF-8 bytes for this attributed string into the provided buffer.
*
* <p>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.</p>
*
* @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
* Write ANSI-encoded UTF-8 bytes for a range, carrying style state across calls.
*
* <p>This method does not reset style state on entry and does not emit a final
* {@code \e[0m} reset on exit. The caller manages the terminal style state via
* the {@code state} array:</p>
* <ul>
* <li>{@code state[0]} — current style code (long)</li>
* <li>{@code state[1]} — alt charset active (0 or 1)</li>
* </ul>
*
* <p>For self-contained rendering, initialize {@code state} to {@code {0, 0}}
* and emit a final reset after the call if {@code state[0] != 0}.</p>
*
* @param buf the byte buffer to write UTF-8 bytes and ANSI control sequences to
* @param rangeStart start index in this sequence (inclusive)
* @param rangeEnd end index in this sequence (exclusive)
* @param colors the number of displayable colors to target
* @param force the force mode for color rendering
* @param palette the color palette, or null to use the default
* @param altIn the sequence to enter alternate character set, or null
* @param altOut the sequence to exit alternate character set, or null
* @param state a reusable two-element array tracking style [0] and alt charset [1] state
*/
@SuppressWarnings("java:S107") // parameter count justified: avoids allocation of a parameter object in hot path
void toAnsiBytes(
ByteArrayBuilder buf, int colors, ForceMode force, ColorPalette palette, String altIn, String altOut) {
long style = 0;
long[] colorState = {0, 0}; // [foreground, background]
boolean alt = false;
ByteArrayBuilder buf,
int rangeStart,
int rangeEnd,
int colors,
ForceMode force,
ColorPalette palette,
String altIn,
String altOut,
long[] state) {
long style = state[0];
boolean alt = state[1] != 0;
if (palette == null) {
palette = ColorPalette.DEFAULT;
}
int i = 0;
int len = length();
while (i < len) {
int i = rangeStart;
while (i < rangeEnd) {
char c = substituteChar(charAt(i), altIn, altOut);
alt = emitAltCharset(buf, charAt(i), alt, altIn, altOut);
long s = styleCodeAt(i) & ~F_HIDDEN;
if (style != s) {
emitStyleChange(buf, style, s, colorState, colors, force, palette);
emitStyleChange(buf, style, s, colors, force, palette);
style = s;
}
i += emitUtf8Char(buf, c, i, len);
}
if (alt) {
buf.appendAscii(altOut);
}
if (style != 0) {
buf.csi().appendAscii("0m");
i += emitUtf8Char(buf, c, i, rangeEnd);
}
state[0] = style;
state[1] = alt ? 1 : 0;
}

/**
Expand Down Expand Up @@ -350,42 +368,42 @@ private int emitUtf8Char(ByteArrayBuilder buf, char c, int i, int len) {
* @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 final long COLOR_BITS = F_FOREGROUND | F_BACKGROUND | FG_COLOR | BG_COLOR;

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;
ByteArrayBuilder buf, long prevStyle, long newStyle, int colors, ForceMode force, ColorPalette palette) {
if (newStyle == 0) {
buf.csi().appendAscii("0m");
colorState[0] = 0;
colorState[1] = 0;
return;
}
long d = (prevStyle ^ newStyle) & MASK;
// Fast path: if only text attributes changed (no color change), skip color extraction
if (((prevStyle ^ newStyle) & COLOR_BITS) == 0) {
buf.csi();
boolean first = appendDecorationAttrsB(buf, d, newStyle, true);
first = appendBoldFaintB(buf, d, newStyle, first);
buf.appendAscii('m');
return;
}
long fg = (newStyle & F_FOREGROUND) != 0 ? newStyle & (FG_COLOR | F_FOREGROUND) : 0;
long bg = (newStyle & F_BACKGROUND) != 0 ? newStyle & (BG_COLOR | F_BACKGROUND) : 0;
long prevFg = (prevStyle & F_FOREGROUND) != 0 ? prevStyle & (FG_COLOR | F_FOREGROUND) : 0;
long prevBg = (prevStyle & F_BACKGROUND) != 0 ? prevStyle & (BG_COLOR | F_BACKGROUND) : 0;
buf.csi();
boolean first = true;
first = appendDecorationAttrsB(buf, d, newStyle, first);
if (colorState[0] != fg) {
if (prevFg != fg) {
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) {
if (prevBg != bg) {
first = appendColorB(buf, bg, false, colors, force, palette, first);
colorState[1] = bg;
}
first = appendBoldFaintB(buf, d, newStyle, first);
buf.appendAscii('m');
Expand Down Expand Up @@ -812,6 +830,8 @@ public AttributedString substring(int start, int end) {

protected abstract char[] buffer();

abstract long[] styleBuffer();

protected abstract int offset();

@Override
Expand Down Expand Up @@ -927,10 +947,54 @@ public int columnLength() {
* @return the display width in columns
*/
public int columnLength(Terminal terminal) {
return columnLength(terminal, 0, length());
}

/**
* Returns the display width in columns for a range of this attributed string.
*
* <p>This is an allocation-free alternative to creating a subSequence and calling
* {@link #columnLength(Terminal)} on it.</p>
*
* @param terminal the terminal to query for grapheme cluster mode, or {@code null}
* @param rangeStart start index in this sequence (inclusive)
* @param rangeEnd end index in this sequence (exclusive)
* @return the display width in columns for the specified range
* @since 4.1.0
*/
public int columnLength(Terminal terminal, int rangeStart, int rangeEnd) {
BreakIterator bi = WCWidth.HAS_JDK_GRAPHEME_SUPPORT ? BreakIterator.getCharacterInstance() : null;
return columnLength(terminal, bi, new WCWidth.CharSequenceCharacterIterator(), rangeStart, rangeEnd);
}

/**
* Returns the display width in columns for a range of this attributed string,
* using a pre-allocated {@link BreakIterator} and {@link WCWidth.CharSequenceCharacterIterator}
* to avoid per-call allocation.
*
* @param terminal the terminal to query for grapheme cluster mode, or {@code null}
* @param rangeStart start index in this sequence (inclusive)
* @param rangeEnd end index in this sequence (exclusive)
* @param bi a pre-allocated BreakIterator, or {@code null}
* @param iter a reusable CharSequenceCharacterIterator
* @return the display width in columns for the specified range
*/
int columnLength(
Terminal terminal,
BreakIterator bi,
WCWidth.CharSequenceCharacterIterator iter,
int rangeStart,
int rangeEnd) {
int cols = 0;
int len = length();
BreakIterator bi = WCWidth.createGraphemeBreakIterator(this);
for (int cur = 0; cur < len; ) {
if (bi != null
&& ((terminal != null && terminal.getGraphemeClusterMode())
|| (terminal == null && WCWidth.HAS_JDK_GRAPHEME_SUPPORT))) {
WCWidth.resetGraphemeBreakIterator(bi, this, iter);
} else {
bi = null;
}
int cur = rangeStart;
while (cur < rangeEnd) {
int charCount = WCWidth.charCountForDisplay(this, cur, terminal, bi);
int w = isHidden(cur) ? 0 : WCWidth.wcwidthForDisplay(this, cur, terminal, charCount);
cur += charCount;
Expand Down
7 changes: 6 additions & 1 deletion terminal/src/main/java/org/jline/utils/AttributedString.java
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public AttributedString(CharSequence str, int start, int end, AttributedStyle s)
* @param start the start index in the buffers
* @param end the end index in the buffers
*/
AttributedString(char[] buffer, long[] style, int start, int end) {
protected AttributedString(char[] buffer, long[] style, int start, int end) {
this.buffer = buffer;
this.style = style;
this.start = start;
Expand Down Expand Up @@ -335,6 +335,11 @@ protected char[] buffer() {
return buffer;
}

@Override
long[] styleBuffer() {
return style;
}

/**
* Returns the offset in the buffer where this attributed string starts.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ protected char[] buffer() {
return buffer;
}

@Override
long[] styleBuffer() {
return style;
}

/**
* Returns the offset in the buffer where this attributed string builder starts.
*
Expand Down
110 changes: 81 additions & 29 deletions terminal/src/main/java/org/jline/utils/DiffHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,49 +96,101 @@ public String toString() {
}

/**
* Compute a list of difference between two lines.
* The result will contain at most 4 Diff objects, as the method
* aims to return the common prefix, inserted text, deleted text and
* common suffix.
* The computation is done on characters and their attributes expressed
* as ansi sequences.
* Compute the common prefix and suffix lengths between two attributed strings
* without any object allocation.
*
* @param text1 the old line
* @param text2 the new line
* @return a list of Diff
* <p>This is an allocation-free alternative to {@link #diff(AttributedString, AttributedString)}
* that stores the common prefix length in {@code result[0]} and the common suffix length
* in {@code result[1]}. The caller can derive the diff segments from these two values:
* <ul>
* <li>EQUAL prefix: {@code [s1, s1 + result[0])}</li>
* <li>INSERT: {@code [s2 + result[0], e2 - result[1])}</li>
* <li>DELETE: {@code [s1 + result[0], e1 - result[1])}</li>
* <li>EQUAL suffix: {@code [e1 - result[1], e1)}</li>
* </ul>
*
* @param text1 the old line
* @param s1 start index in text1 (inclusive)
* @param e1 end index in text1 (exclusive)
* @param text2 the new line
* @param s2 start index in text2 (inclusive)
* @param e2 end index in text2 (exclusive)
* @param result a reusable two-element array; on return result[0] = commonStart, result[1] = commonEnd
*/
public static List<Diff> diff(AttributedString text1, AttributedString text2) {
int l1 = text1.length();
int l2 = text2.length();
int n = Math.min(l1, l2);
static void diff(AttributedString text1, int s1, int e1, AttributedString text2, int s2, int e2, int[] result) {
int n = Math.min(e1 - s1, e2 - s2);
int commonStart = commonPrefixLength(text1, s1, e1, text2, s2, e2, n);
result[0] = commonStart;
result[1] = commonSuffixLength(text1, e1, text2, e2, n - commonStart);
}

/**
* Scan forward for the common prefix length, respecting hidden-range boundaries.
*/
private static int commonPrefixLength(
AttributedString text1, int s1, int e1, AttributedString text2, int s2, int e2, int n) {
int commonStart = 0;
// Given a run of contiguous "hidden" characters (which are
// sequences of uninterrupted escape sequences) we always want to
// print either the entire run or none of it - never a part of it.
int startHiddenRange = -1;
while (commonStart < n
&& text1.charAt(commonStart) == text2.charAt(commonStart)
&& text1.styleAt(commonStart).equals(text2.styleAt(commonStart))) {
if (text1.isHidden(commonStart)) {
&& text1.charAt(s1 + commonStart) == text2.charAt(s2 + commonStart)
&& text1.styleCodeAt(s1 + commonStart) == text2.styleCodeAt(s2 + commonStart)) {
if (text1.isHidden(s1 + commonStart)) {
if (startHiddenRange < 0) startHiddenRange = commonStart;
} else startHiddenRange = -1;
} else {
startHiddenRange = -1;
}
commonStart++;
}
if (startHiddenRange >= 0
&& ((l1 > commonStart && text1.isHidden(commonStart))
|| (l2 > commonStart && text2.isHidden(commonStart)))) commonStart = startHiddenRange;
&& (((e1 - s1) > commonStart && text1.isHidden(s1 + commonStart))
|| ((e2 - s2) > commonStart && text2.isHidden(s2 + commonStart)))) {
commonStart = startHiddenRange;
}
return commonStart;
}

startHiddenRange = -1;
/**
* Scan backward for the common suffix length, respecting hidden-range boundaries.
*/
private static int commonSuffixLength(AttributedString text1, int e1, AttributedString text2, int e2, int n) {
int commonEnd = 0;
while (commonEnd < n - commonStart
&& text1.charAt(l1 - commonEnd - 1) == text2.charAt(l2 - commonEnd - 1)
&& text1.styleAt(l1 - commonEnd - 1).equals(text2.styleAt(l2 - commonEnd - 1))) {
if (text1.isHidden(l1 - commonEnd - 1)) {
int startHiddenRange = -1;
while (commonEnd < n
&& text1.charAt(e1 - commonEnd - 1) == text2.charAt(e2 - commonEnd - 1)
&& text1.styleCodeAt(e1 - commonEnd - 1) == text2.styleCodeAt(e2 - commonEnd - 1)) {
if (text1.isHidden(e1 - commonEnd - 1)) {
if (startHiddenRange < 0) startHiddenRange = commonEnd;
} else startHiddenRange = -1;
} else {
startHiddenRange = -1;
}
commonEnd++;
}
if (startHiddenRange >= 0) commonEnd = startHiddenRange;
if (startHiddenRange >= 0
&& commonEnd < n
&& (text1.isHidden(e1 - commonEnd - 1) || text2.isHidden(e2 - commonEnd - 1))) {
commonEnd = startHiddenRange;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return commonEnd;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Compute the differences between two attributed strings.
*
* <p>Returns a list of {@link Diff} operations (EQUAL, INSERT, DELETE) that
* transform {@code text1} into {@code text2}. Hidden character ranges are
* kept intact — they are never split across diff segments.</p>
*
* @param text1 the original text
* @param text2 the modified text
* @return a list of diff operations
*/
public static List<Diff> diff(AttributedString text1, AttributedString text2) {
int l1 = text1.length();
int l2 = text2.length();
int[] result = new int[2];
diff(text1, 0, l1, text2, 0, l2, result);
int commonStart = result[0];
int commonEnd = result[1];
LinkedList<Diff> diffs = new LinkedList<>();
if (commonStart > 0) {
diffs.add(new Diff(DiffHelper.Operation.EQUAL, text1.subSequence(0, commonStart)));
Expand Down
Loading