diff --git a/pom.xml b/pom.xml index 563419d..504b5cd 100644 --- a/pom.xml +++ b/pom.xml @@ -73,7 +73,7 @@ ${google-java-format.version} - + @@ -91,8 +91,9 @@ 8 8 - 11 - 11 + 15 + 15 + diff --git a/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Style.java b/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Style.java index 80a78f8..1d8a044 100644 --- a/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Style.java +++ b/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Style.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import org.codejive.twinkle.util.Printable; import org.jspecify.annotations.NonNull; public class Style implements Printable { @@ -18,6 +19,7 @@ public class Style implements Printable { private static final long IDX_CROSSEDOUT = 7; // private static final long DOUBLEUNDERLINE = 8; + public static final long F_UNKNOWN = -1L; public static final long F_UNSTYLED = 0L; public static final long F_BOLD = 1 << IDX_BOLD; public static final long F_FAINT = 1 << IDX_FAINT; @@ -229,7 +231,7 @@ public boolean isStrikethrough() { } public @NonNull Style fgColor(@NonNull Color color) { - long newState = (state & ~MASK_FG_COLOR) | (encodeColor(color) << SHIFT_FG_COLOR); + long newState = applyFgColor(state, color); return of(newState); } @@ -239,7 +241,7 @@ public boolean isStrikethrough() { } public @NonNull Style bgColor(@NonNull Color color) { - long newState = (state & ~MASK_BG_COLOR) | (encodeColor(color) << SHIFT_BG_COLOR); + long newState = applyBgColor(state, color); return of(newState); } @@ -315,6 +317,140 @@ private static long encodeColor(@NonNull Color color) { return result; } + public static long parse(@NonNull String ansiSequence) { + return parse(F_UNSTYLED, ansiSequence); + } + + public static long parse(long currentStyleState, @NonNull String ansiSequence) { + if (!ansiSequence.startsWith(Ansi.CSI) || !ansiSequence.endsWith("m")) { + return currentStyleState; + } + + String content = ansiSequence.substring(2, ansiSequence.length() - 1); + String[] parts = content.split("[;:]", -1); + int[] codes = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + try { + // Empty parameters are assumed to be 0 otherwise parse as integer + codes[i] = parts[i].isEmpty() ? 0 : Integer.parseInt(parts[i]); + } catch (NumberFormatException e) { + codes[i] = -1; // Invalid code, will be ignored + } + } + + long state = currentStyleState; + for (int i = 0; i < codes.length; i++) { + int code = codes[i]; + switch (code) { + case -1: + // Invalid code, ignore + break; + case 0: + state = 0; + break; + case 1: + state |= F_BOLD; + break; + case 2: + state |= F_FAINT; + break; + case 3: + state |= F_ITALIC; + break; + case 4: + state |= F_UNDERLINED; + break; + case 5: + state |= F_BLINK; + break; + case 7: + state |= F_INVERSE; + break; + case 8: + state |= F_HIDDEN; + break; + case 9: + state |= F_STRIKETHROUGH; + break; + case 22: + state &= ~(F_BOLD | F_FAINT); + break; + case 23: + state &= ~F_ITALIC; + break; + case 24: + state &= ~F_UNDERLINED; + break; + case 25: + state &= ~F_BLINK; + break; + case 27: + state &= ~F_INVERSE; + break; + case 28: + state &= ~F_HIDDEN; + break; + case 29: + state &= ~F_STRIKETHROUGH; + break; + case 39: + state &= ~MASK_FG_COLOR; + break; + case 49: + state &= ~MASK_BG_COLOR; + break; + default: + if (code >= 30 && code <= 37) { + Color c = Color.basic(code - 30, Color.BasicColor.Intensity.normal); + state = applyFgColor(state, c); + } else if (code >= 90 && code <= 97) { + Color c = Color.basic(code - 90, Color.BasicColor.Intensity.bright); + state = applyFgColor(state, c); + } else if (code >= 40 && code <= 47) { + Color c = Color.basic(code - 40, Color.BasicColor.Intensity.normal); + state = applyBgColor(state, c); + } else if (code >= 100 && code <= 107) { + Color c = Color.basic(code - 100, Color.BasicColor.Intensity.bright); + state = applyBgColor(state, c); + } else if (code == 38 || code == 48) { + boolean isFg = (code == 38); + if (i + 1 < codes.length) { + int type = codes[i + 1]; + if (type == 5 && i + 2 < codes.length) { + Color c = Color.indexed(codes[i + 2]); + if (isFg) { + state = applyFgColor(state, c); + } else { + state = applyBgColor(state, c); + } + i += 2; + } else if (type == 2 && i + 4 < codes.length) { + Color c = Color.rgb(codes[i + 2], codes[i + 3], codes[i + 4]); + if (isFg) { + state = applyFgColor(state, c); + } else { + state = applyBgColor(state, c); + } + i += 4; + } + } + } + break; + } + } + return state; + } + + private static long applyFgColor(long state, Color color) { + long encoded = encodeColor(color); + return (state & ~MASK_FG_COLOR) | (encoded << SHIFT_FG_COLOR); + } + + private static long applyBgColor(long state, Color color) { + long encoded = encodeColor(color); + return (state & ~MASK_BG_COLOR) | (encoded << SHIFT_BG_COLOR); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -332,6 +468,10 @@ public int hashCode() { public String toString() { StringBuilder sb = new StringBuilder(); sb.append("StyleImpl{"); + if (state == F_UNKNOWN) { + sb.append("UNKNOWN}"); + return sb.toString(); + } if (isBold()) sb.append("bold, "); if (isFaint()) sb.append("faint, "); if (isItalic()) sb.append("italic, "); @@ -374,6 +514,14 @@ public String toString() { @Override public @NonNull Appendable toAnsi(Appendable appendable, long currentStyleState) throws IOException { + if (state == F_UNKNOWN) { + // Do nothing, we keep the current state + return appendable; + } + if (currentStyleState == F_UNKNOWN) { + appendable.append(Ansi.STYLE_RESET); + currentStyleState = F_UNSTYLED; + } List styles = new ArrayList<>(); if ((currentStyleState & (F_BOLD | F_FAINT)) != (state & (F_BOLD | F_FAINT))) { // First we switch to NORMAL to clear both BOLD and FAINT diff --git a/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Printable.java b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/Printable.java similarity index 82% rename from twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Printable.java rename to twinkle-ansi/src/main/java/org/codejive/twinkle/util/Printable.java index dd2c3fb..9780e4e 100644 --- a/twinkle-ansi/src/main/java/org/codejive/twinkle/ansi/Printable.java +++ b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/Printable.java @@ -1,6 +1,7 @@ -package org.codejive.twinkle.ansi; +package org.codejive.twinkle.util; import java.io.IOException; +import org.codejive.twinkle.ansi.Style; import org.jspecify.annotations.NonNull; public interface Printable { @@ -10,7 +11,9 @@ public interface Printable { * * @return The ANSI string representation of the object. */ - @NonNull String toAnsiString(); + default @NonNull String toAnsiString() { + return toAnsiString(Style.F_UNKNOWN); + } /** * Outputs the object as an ANSI string, including ANSI escape codes for styles. This method @@ -19,7 +22,9 @@ public interface Printable { * @param appendable The Appendable to write the ANSI output to. * @return The Appendable passed as parameter. */ - @NonNull Appendable toAnsi(Appendable appendable) throws IOException; + default @NonNull Appendable toAnsi(Appendable appendable) throws IOException { + return toAnsi(appendable, Style.F_UNKNOWN); + } /** * Converts the object to an ANSI string, including ANSI escape codes for styles. This method @@ -55,7 +60,14 @@ public interface Printable { * @param currentStyleState The current style to start with. * @return The ANSI string representation of the object. */ - @NonNull String toAnsiString(long currentStyleState); + default @NonNull String toAnsiString(long currentStyleState) { + StringBuilder sb = new StringBuilder(); + try { + return toAnsi(sb, currentStyleState).toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } /** * Outputs the object as an ANSI string, including ANSI escape codes for styles. This method diff --git a/twinkle-ansi/src/main/java/org/codejive/twinkle/util/SequenceIterator.java b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/SequenceIterator.java new file mode 100644 index 0000000..a3c6d7c --- /dev/null +++ b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/SequenceIterator.java @@ -0,0 +1,478 @@ +package org.codejive.twinkle.util; + +import java.io.IOException; +import java.io.PushbackReader; +import java.io.Reader; +import java.util.NoSuchElementException; +import org.codejive.twinkle.ansi.Ansi; + +/** + * An iterator that reads from a text source and yields Unicode grapheme clusters, ANSI escape + * sequences and simple codepoints while handling line endings as single units (even when in reality + * they might be 2 characters). + */ +public interface SequenceIterator { + /** Returns true if there is still input to read. */ + boolean hasNext(); + + /** + * Returns the next lead codepoint of the next sequence. Call {@link #sequence()} to get the + * full sequence. In case of ANSI escape sequences, this returns ESC (0x1B). In case of line + * endings, this returns NEWLINE (0x0A). + * + * @throws NoSuchElementException if there is no more input. + */ + int next(); + + /** + * Returns true if the last returned sequence from {@link #next()} is complex, i.e., consists of + * multiple codepoints or is an escape sequence. + */ + boolean isComplex(); + + /** + * Returns the visual width of the current sequence in columns. Escape sequences return 0, most + * characters return 1, and Full-width/Wide characters return 2. + */ + int width(); + + /** Returns the full sequence of the last returned codepoint from {@link #next()}. */ + String sequence(); + + /** Returns the start index of the current sequence in characters. */ + int begin(); + + /** Returns the end index of the current sequence in characters. */ + int end(); + + static SequenceIterator of(CharSequence text) { + return new CharSequenceSequenceIterator(text); + } + + static SequenceIterator of(Reader input) { + return new ReaderSequenceIterator(input); + } +} + +abstract class BaseSequenceIterator implements SequenceIterator { + protected int currentWidth = 0; + + protected boolean shouldBreak(int prev, int curr, int riCount) { + if (isL(prev) && (isL(curr) || isV(curr) || isLV(curr) || isLVT(curr))) return false; + if ((isLV(prev) || isV(prev)) && (isV(curr) || isT(curr))) return false; + if ((isLVT(prev) || isT(prev)) && isT(curr)) return false; + int type = Character.getType(curr); + if (type == Character.NON_SPACING_MARK + || type == Character.COMBINING_SPACING_MARK + || curr == 0x200D + || prev == 0x200D) return false; + if (isRegionalIndicator(prev) && isRegionalIndicator(curr)) return (riCount % 2 == 0); + return !(isPrepend(prev) || isVirama(prev)); + } + + protected static boolean isRegionalIndicator(int cp) { + return cp >= 0x1F1E6 && cp <= 0x1F1FF; + } + + protected static boolean isL(int cp) { + return (cp >= 0x1100 && cp <= 0x115F); + } + + protected static boolean isV(int cp) { + return (cp >= 0x1160 && cp <= 0x11A7); + } + + protected static boolean isT(int cp) { + return (cp >= 0x11A8 && cp <= 0x11FF); + } + + protected static boolean isLV(int cp) { + return (cp >= 0xAC00 && cp <= 0xD7A3 && (cp - 0xAC00) % 28 == 0); + } + + protected static boolean isLVT(int cp) { + return (cp >= 0xAC00 && cp <= 0xD7A3 && (cp - 0xAC00) % 28 != 0); + } + + protected static boolean isVirama(int cp) { + return (cp >= 0x094D && cp <= 0x0D4D && (cp & 0xFF) == 0x4D) || cp == 0x0D4D; + } + + protected static boolean isPrepend(int cp) { + return cp == 0x0600 + || cp == 0x0601 + || cp == 0x0602 + || cp == 0x0603 + || cp == 0x0604 + || cp == 0x0605 + || cp == 0x06DD + || cp == 0x070F; + } + + @Override + public int width() { + return currentWidth; + } + + protected int calculateWidth(int cp) { + // ANSI escapes and Control characters (except space) are 0 width + if (cp == Ansi.ESC || (cp < 0x20 && cp != '\n' && cp != '\r') || cp == 0x7F) { + return 0; + } + // Line breaks are handled as 0-width movements + if (cp == '\n' || cp == '\r') { + return 0; + } + + if (isWide(cp)) { + return 2; + } + + return 1; + } + + private boolean isWide(int cp) { + // East Asian Wide (W) and Fullwidth (F) + if ((cp >= 0x1100 && cp <= 0x115F) + || // Hangul Jamo + (cp >= 0x2E80 && cp <= 0xA4CF && cp != 0x303F) + || // CJK Radicals, Symbols, Han + (cp >= 0xAC00 && cp <= 0xD7A3) + || // Hangul Syllables + (cp >= 0xF900 && cp <= 0xFAFF) + || // CJK Compatibility Ideographs + (cp >= 0xFE10 && cp <= 0xFE19) + || // Vertical forms + (cp >= 0xFE30 && cp <= 0xFE6F) + || // CJK Compatibility Forms + (cp >= 0xFF00 && cp <= 0xFF60) + || // Fullwidth Forms + (cp >= 0xFFE0 && cp <= 0xFFE6)) { + return true; + } + + // Plane 2 and 3 (SIP/TIP) are almost entirely CJK Ideographs (Wide) + if (cp >= 0x20000 && cp <= 0x3FFFD) { + return true; + } + + // Common Emoji Presentation ranges (Simplified) + // Includes Miscellaneous Symbols and Pictographs, Emoticons, Transport, etc. + if ((cp >= 0x1F300 && cp <= 0x1F64F) + || (cp >= 0x1F680 && cp <= 0x1F6FF) + || (cp >= 0x1F900 && cp <= 0x1F9FF) + || (cp >= 0x1F200 && cp <= 0x1F2FF)) { + return true; + } + + return false; + } +} + +/** + * A high-performance implementation of SequenceIterator that operates directly on a CharSequence + * without using Streams or Readers. + */ +class CharSequenceSequenceIterator extends BaseSequenceIterator { + private final CharSequence text; + private final int length; + + private int cursor = 0; + private int sequenceStart = 0; + private int sequenceEnd = 0; + private int nextLeadCodePoint = -1; + private boolean primed = false; + + public CharSequenceSequenceIterator(CharSequence text) { + this.text = text; + length = text.length(); + } + + @Override + public boolean hasNext() { + if (!primed) { + primeNext(); + } + return cursor < length || primed && nextLeadCodePoint != -1; + } + + @Override + public int next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + primed = false; + return nextLeadCodePoint; + } + + @Override + public boolean isComplex() { + return (sequenceEnd - sequenceStart) > Character.charCount(nextLeadCodePoint); + } + + @Override + public String sequence() { + return text.subSequence(sequenceStart, sequenceEnd).toString(); + } + + @Override + public int begin() { + return sequenceStart; + } + + @Override + public int end() { + return sequenceEnd; + } + + private void primeNext() { + if (cursor >= length) { + nextLeadCodePoint = -1; + return; + } + + sequenceStart = cursor; + int cp = Character.codePointAt(text, cursor); + nextLeadCodePoint = cp; + currentWidth = calculateWidth(cp); + cursor += Character.charCount(cp); + + if (cp == Ansi.ESC) { + consumeAnsi(); + } else if (cp == '\r' || cp == '\n') { + if (cp == '\r' && cursor < length && text.charAt(cursor) == '\n') { + cursor++; // Consume the \n of \r\n + } + nextLeadCodePoint = '\n'; + } else { + int riCount = isRegionalIndicator(cp) ? 1 : 0; + int prevCp = cp; + + while (cursor < length) { + int curr = Character.codePointAt(text, cursor); + + if (curr == '\r' + || curr == '\n' + || curr == Ansi.ESC + || shouldBreak(prevCp, curr, riCount)) { + break; + } + + cursor += Character.charCount(curr); + riCount = isRegionalIndicator(curr) ? riCount + 1 : 0; + prevCp = curr; + } + } + + sequenceEnd = cursor; + primed = true; + } + + private void consumeAnsi() { + if (cursor >= length) return; + char c = text.charAt(cursor++); + + if (c == '[') { + // --- CSI (Control Sequence Introducer) --- + // Format: ESC [ (Parameters) (Intermediate bytes) (Final Byte) + // The Final Byte is always in the range 0x40 to 0x7E. + while (cursor < length) { + char n = text.charAt(cursor++); + if (n >= 0x40 && n <= 0x7E) break; + } + } else if (c == ']') { + // --- OSC (Operating System Command) --- + // Format: ESC ] (Command string) (Terminator) + // Terminator is usually BEL (0x07) or ST (ESC \) + while (cursor < length) { + char n = text.charAt(cursor++); + if (n == 0x07) break; // BEL + if (n == Ansi.ESC && cursor < length && text.charAt(cursor) == '\\') { + cursor++; // Consume '\' + break; + } + } + } + } +} + +/** + * An iterator that reads from a Reader and yields Unicode grapheme clusters, ANSI escape sequences + * and simple codepoints while handling line endings as single units (even when in reality they + * might be 2 characters). + */ +class ReaderSequenceIterator extends BaseSequenceIterator { + private final PushbackReader reader; + private final StringBuilder currentSequence = new StringBuilder(); + private int nextLeadCodePoint = -1; + private boolean primed = false; + private boolean exhausted = false; + private int position = 0; + private int sequenceStart = 0; + + private static final int NEWLINE = '\n'; + + /** Creates a SequenceIterator that reads from the given Reader. */ + ReaderSequenceIterator(Reader reader) { + this.reader = new PushbackReader(reader, 4); + } + + @Override + public boolean hasNext() { + if (!primed) { + primeNext(); + } + return !exhausted; + } + + @Override + public int next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + primed = false; + return nextLeadCodePoint; + } + + @Override + public boolean isComplex() { + return sequence().length() > Character.charCount(nextLeadCodePoint); + } + + @Override + public String sequence() { + return currentSequence.toString(); + } + + /** Returns the start index of the current sequence in characters. */ + @Override + public int begin() { + return sequenceStart; + } + + /** Returns the end index of the current sequence in characters. */ + @Override + public int end() { + return sequenceStart + currentSequence.length(); + } + + private void primeNext() { + currentSequence.setLength(0); + sequenceStart = position; + try { + int cp = readCodePoint(); + if (cp == -1) { + exhausted = true; + nextLeadCodePoint = -1; + return; + } + + nextLeadCodePoint = cp; + currentWidth = calculateWidth(cp); + currentSequence.append(Character.toChars(cp)); + + if (cp == Ansi.ESC) { + consumeAnsi(currentSequence); + } else if (cp == '\r' || cp == '\n') { + if (cp == '\r') { + int next = read(); + if (next == '\n') { + currentSequence.append('\n'); + } else if (next != -1) { + unread(next); + } + } + nextLeadCodePoint = NEWLINE; + } else { + int riCount = isRegionalIndicator(cp) ? 1 : 0; + int prevCp = cp; + while (true) { + int curr = readCodePoint(); + if (curr == -1) break; + if (curr == '\r' + || curr == '\n' + || curr == Ansi.ESC + || shouldBreak(prevCp, curr, riCount)) { + unreadCodePoint(curr); + break; + } + currentSequence.append(Character.toChars(curr)); + riCount = isRegionalIndicator(curr) ? riCount + 1 : 0; + prevCp = curr; + } + } + } catch (IOException e) { + exhausted = true; + } + primed = true; + } + + private int readCodePoint() throws IOException { + int c1 = read(); + if (c1 == -1) return -1; + if (Character.isHighSurrogate((char) c1)) { + int c2 = read(); + if (c2 != -1) return Character.toCodePoint((char) c1, (char) c2); + } + return c1; + } + + private void unreadCodePoint(int cp) throws IOException { + char[] chars = Character.toChars(cp); + for (int i = chars.length - 1; i >= 0; i--) unread(chars[i]); + } + + private void consumeAnsi(StringBuilder sb) throws IOException { + int c = read(); + if (c == -1) return; + sb.append((char) c); + + if (c == '[') { + // --- CSI (Control Sequence Introducer) --- + // Format: ESC [ (Parameters) (Intermediate bytes) (Final Byte) + // The Final Byte is always in the range 0x40 to 0x7E. + while (true) { + int n = read(); + if (n == -1) break; + sb.append((char) n); + if (n >= 0x40 && n <= 0x7E) + break; // Reached the 'Final Byte' (e.g., 'm' in ESC[31m) + } + } else if (c == ']') { + // --- OSC (Operating System Command) --- + // Format: ESC ] (Command string) (Terminator) + // Terminator is usually BEL (0x07) or ST (ESC \) + while (true) { + int n = read(); + if (n == -1) break; + + if (n == 0x07) { // Standard BEL terminator + sb.append((char) n); + break; + } + if (n == Ansi.ESC) { // Check for ST terminator (ESC \) + int n2 = read(); + if (n2 == '\\') { + sb.append((char) n).append((char) n2); + break; + } + if (n2 != -1) unread(n2); + } + sb.append((char) n); + } + } + } + + private int read() throws IOException { + int c = reader.read(); + if (c != -1) { + position++; + } + return c; + } + + private void unread(int c) throws IOException { + reader.unread(c); + position--; + } +} diff --git a/twinkle-ansi/src/main/java/org/codejive/twinkle/util/StyledIterator.java b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/StyledIterator.java new file mode 100644 index 0000000..277ad87 --- /dev/null +++ b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/StyledIterator.java @@ -0,0 +1,138 @@ +package org.codejive.twinkle.util; + +import java.io.Reader; +import java.util.NoSuchElementException; +import org.codejive.twinkle.ansi.Ansi; +import org.codejive.twinkle.ansi.Style; + +/** + * An iterator that wraps a SequenceIterator and tracks the current style state based on ANSI escape + * sequences encountered in the input. + */ +public class StyledIterator implements SequenceIterator { + private final SequenceIterator delegate; + private long currentStyleState; + private int nextCodePoint = -1; + private boolean primed = false; + private boolean exhausted = false; + + /** + * Creates a StyledIterator that wraps the given SequenceIterator and starts with an unknown + * initial style state. + */ + protected StyledIterator(SequenceIterator delegate) { + this(delegate, Style.F_UNKNOWN); + } + + /** + * Creates a StyledIterator that wraps the given SequenceIterator and starts with the given + * initial style state. + */ + public StyledIterator(SequenceIterator delegate, long currentStyleState) { + this.delegate = delegate; + this.currentStyleState = currentStyleState; + } + + /** Returns true if there is still input to read. */ + @Override + public boolean hasNext() { + if (!primed) { + primeNext(); + } + return !exhausted; + } + + /** + * Returns the next lead codepoint of the next sequence, skipping over any ANSI escape sequences + * and updating the current style state. Call {@link #sequence()} to get the full sequence and + * {@link #style()} to get the current style. In case of line endings, this returns NEWLINE + * (0x0A). + * + * @throws NoSuchElementException if there is no more input. + */ + @Override + public int next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + primed = false; + return nextCodePoint; + } + + @Override + public boolean isComplex() { + return delegate.isComplex(); + } + + @Override + public int width() { + return delegate.width(); + } + + @Override + public int begin() { + return delegate.begin(); + } + + @Override + public int end() { + return delegate.end(); + } + + @Override + public String sequence() { + return delegate.sequence(); + } + + /** Returns the current style based on the ANSI escape sequences encountered so far. */ + public Style style() { + return Style.of(currentStyleState); + } + + /** Returns the current style state based on the ANSI escape sequences encountered so far. */ + public long styleState() { + return currentStyleState; + } + + private void primeNext() { + while (delegate.hasNext()) { + int cp = delegate.next(); + if (cp == Ansi.ESC) { + String ansiSequence = delegate.sequence(); + if (ansiSequence.startsWith(Ansi.CSI) && ansiSequence.endsWith("m")) { + currentStyleState = Style.parse(currentStyleState, ansiSequence); + } + } else { + nextCodePoint = cp; + primed = true; + return; + } + } + exhausted = true; + primed = true; + } + + public static StyledIterator of(CharSequence text) { + return of(text, Style.F_UNKNOWN); + } + + public static StyledIterator of(Reader input) { + return of(input, Style.F_UNKNOWN); + } + + public static StyledIterator of(SequenceIterator iter) { + return of(iter, Style.F_UNKNOWN); + } + + public static StyledIterator of(CharSequence text, long currentStyleState) { + return of(SequenceIterator.of(text), currentStyleState); + } + + public static StyledIterator of(Reader input, long currentStyleState) { + return of(SequenceIterator.of(input), currentStyleState); + } + + public static StyledIterator of(SequenceIterator iter, long currentStyleState) { + return new StyledIterator(iter, currentStyleState); + } +} diff --git a/twinkle-ansi/src/main/java/org/codejive/twinkle/util/Unicode.java b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/Unicode.java new file mode 100644 index 0000000..32e9296 --- /dev/null +++ b/twinkle-ansi/src/main/java/org/codejive/twinkle/util/Unicode.java @@ -0,0 +1,13 @@ +package org.codejive.twinkle.util; + +public class Unicode { + public static int visibleWidth(CharSequence text) { + int width = 0; + SequenceIterator si = SequenceIterator.of(text); + while (si.hasNext()) { + si.next(); + width += si.width(); + } + return width; + } +} diff --git a/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestSequenceIterator.java b/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestSequenceIterator.java new file mode 100644 index 0000000..73e9aea --- /dev/null +++ b/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestSequenceIterator.java @@ -0,0 +1,269 @@ +package org.codejive.twinkle.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +public class TestSequenceIterator { + + @Test + public void testNormalText() { + String input = "abc 123"; + SequenceIterator it = SequenceIterator.of(input); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('a'); + assertThat(it.sequence()).isEqualTo("a"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('b'); + assertThat(it.sequence()).isEqualTo("b"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('c'); + assertThat(it.sequence()).isEqualTo("c"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo(' '); + assertThat(it.sequence()).isEqualTo(" "); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('1'); + assertThat(it.sequence()).isEqualTo("1"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('2'); + assertThat(it.sequence()).isEqualTo("2"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('3'); + assertThat(it.sequence()).isEqualTo("3"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testSurrogatePairs() { + // G clef character: U+1D11E (surrogate pair \uD834\uDD1E) + String input = "\uD834\uDD1E"; + SequenceIterator it = SequenceIterator.of(input); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo(0x1D11E); + assertThat(it.sequence()).isEqualTo("\uD834\uDD1E"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testComplexGraphemes() { + // Family emoji: Man + ZWJ + Woman + ZWJ + Girl + ZWJ + Boy + // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466 + String familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"; + // Letter 'a' with acute accent: a + U+0301 + String aAcute = "a\u0301"; + + String input = familyEmoji + aAcute; + SequenceIterator it = SequenceIterator.of(input); + + // Test Family Emoji + assertThat(it.hasNext()).isTrue(); + // next() returns the first code point of the sequence + assertThat(it.next()).isEqualTo(0x1F468); + assertThat(it.sequence()).isEqualTo(familyEmoji); + assertThat(it.width()).isEqualTo(2); + assertThat(it.isComplex()).isTrue(); + + // Test Combining Character + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('a'); + assertThat(it.sequence()).isEqualTo(aAcute); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isTrue(); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testAnsiEscapeSequences() { + // CSI: Red color + String csi = "\u001B[31m"; + // OSC: Window title with BEL terminator + String oscBel = "\u001B]0;Title\u0007"; + // OSC: Hyperlink with ST (ESC \) terminator + String oscSt = "\u001B]8;;http://example.com\u001B\\"; + + String input = csi + "A" + oscBel + "B" + oscSt; + SequenceIterator it = SequenceIterator.of(input); + + // CSI + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo(0x1B); + assertThat(it.sequence()).isEqualTo(csi); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + // Normal char 'A' + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('A'); + assertThat(it.sequence()).isEqualTo("A"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // OSC BEL + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo(0x1B); + assertThat(it.sequence()).isEqualTo(oscBel); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + // Normal char 'B' + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('B'); + assertThat(it.sequence()).isEqualTo("B"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // OSC ST + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo(0x1B); + assertThat(it.sequence()).isEqualTo(oscSt); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testNewlines() { + // \n, \r\n, \r should all be treated as newlines + String input = "a\nb\r\nc\rd"; + SequenceIterator it = SequenceIterator.of(input); + + // a + it.next(); + assertThat(it.sequence()).isEqualTo("a"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // \n + assertThat(it.next()).isEqualTo('\n'); + assertThat(it.sequence()).isEqualTo("\n"); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isFalse(); + + // b + it.next(); + assertThat(it.sequence()).isEqualTo("b"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // \r\n -> returns \n code point, sequence is \r\n + assertThat(it.next()).isEqualTo('\n'); + assertThat(it.sequence()).isEqualTo("\r\n"); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + // c + it.next(); + assertThat(it.sequence()).isEqualTo("c"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // \r -> returns \n code point, sequence is \r + assertThat(it.next()).isEqualTo('\n'); + assertThat(it.sequence()).isEqualTo("\r"); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isFalse(); + + // d + it.next(); + assertThat(it.sequence()).isEqualTo("d"); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testCombined() { + // "Hi " + FamilyEmoji + " " + RedColor + "Bold" + Reset + String familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"; + String red = "\u001B[31m"; + String reset = "\u001B[0m"; + + String input = "Hi " + familyEmoji + " " + red + "Bold" + reset; + SequenceIterator it = SequenceIterator.of(input); + + // H + assertThat(it.next()).isEqualTo('H'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + // i + assertThat(it.next()).isEqualTo('i'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + // space + assertThat(it.next()).isEqualTo(' '); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // Family Emoji + assertThat(it.next()).isEqualTo(0x1F468); + assertThat(it.sequence()).isEqualTo(familyEmoji); + assertThat(it.width()).isEqualTo(2); + assertThat(it.isComplex()).isTrue(); + + // space + assertThat(it.next()).isEqualTo(' '); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // Red ANSI + assertThat(it.next()).isEqualTo(0x1B); + assertThat(it.sequence()).isEqualTo(red); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + // B + assertThat(it.next()).isEqualTo('B'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + // o + assertThat(it.next()).isEqualTo('o'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + // l + assertThat(it.next()).isEqualTo('l'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + // d + assertThat(it.next()).isEqualTo('d'); + assertThat(it.width()).isEqualTo(1); + assertThat(it.isComplex()).isFalse(); + + // Reset ANSI + assertThat(it.next()).isEqualTo(0x1B); + assertThat(it.sequence()).isEqualTo(reset); + assertThat(it.width()).isEqualTo(0); + assertThat(it.isComplex()).isTrue(); + + assertThat(it.hasNext()).isFalse(); + } +} diff --git a/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestStyledIterator.java b/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestStyledIterator.java new file mode 100644 index 0000000..49b4a02 --- /dev/null +++ b/twinkle-ansi/src/test/java/org/codejive/twinkle/util/TestStyledIterator.java @@ -0,0 +1,81 @@ +package org.codejive.twinkle.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.codejive.twinkle.ansi.Style; +import org.junit.jupiter.api.Test; + +public class TestStyledIterator { + + @Test + public void testPlainSequence() { + SequenceIterator seqIter = SequenceIterator.of("abc"); + StyledIterator it = new StyledIterator(seqIter); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('a'); + assertThat(it.sequence()).isEqualTo("a"); + assertThat(it.styleState()).isEqualTo(Style.F_UNSTYLED); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('b'); + assertThat(it.sequence()).isEqualTo("b"); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('c'); + assertThat(it.sequence()).isEqualTo("c"); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testStyledSequence() { + String red = "\u001B[31m"; + SequenceIterator seqIter = SequenceIterator.of(red + "a"); + StyledIterator it = new StyledIterator(seqIter); + + assertThat(it.hasNext()).isTrue(); + // The iterator should consume the ESC sequence internally and advance to 'a' + assertThat(it.next()).isEqualTo('a'); + assertThat(it.sequence()).isEqualTo("a"); + + // Verify style state changed + assertThat(it.styleState()).isNotEqualTo(Style.F_UNSTYLED); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testSkipNonStyleAnsi() { + String up = "\u001B[1A"; // Cursor Up + SequenceIterator seqIter = SequenceIterator.of(up + "a"); + StyledIterator it = new StyledIterator(seqIter); + + assertThat(it.hasNext()).isTrue(); + assertThat(it.next()).isEqualTo('a'); + // Style should NOT change for non-SGR sequences + assertThat(it.styleState()).isEqualTo(Style.F_UNSTYLED); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testMixedSequences() { + String red = "\u001B[31m"; + String reset = "\u001B[0m"; + SequenceIterator seqIter = SequenceIterator.of("a" + red + "b" + reset + "c"); + StyledIterator it = new StyledIterator(seqIter); + + // 'a' + assertThat(it.next()).isEqualTo('a'); + assertThat(it.styleState()).isEqualTo(Style.F_UNSTYLED); + + // 'b' (RED consumed) + assertThat(it.next()).isEqualTo('b'); + assertThat(it.styleState()).isNotEqualTo(Style.F_UNSTYLED); + + // 'c' (RESET consumed) + assertThat(it.next()).isEqualTo('c'); + assertThat(it.styleState()).isEqualTo(Style.F_UNSTYLED); + } +} diff --git a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/Bar.java b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/Bar.java index 465b398..e12617e 100644 --- a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/Bar.java +++ b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/Bar.java @@ -1,6 +1,6 @@ package org.codejive.twinkle.widgets.graphs.bar; -import org.codejive.twinkle.core.widget.Canvas; +import org.codejive.twinkle.core.text.Canvas; import org.codejive.twinkle.core.widget.Renderable; public class Bar implements Renderable { diff --git a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/FracBarRenderer.java b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/FracBarRenderer.java index 9a75471..c67b212 100644 --- a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/FracBarRenderer.java +++ b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/bar/FracBarRenderer.java @@ -1,8 +1,8 @@ package org.codejive.twinkle.widgets.graphs.bar; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.text.Canvas; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.widgets.graphs.bar.BarConfig.*; import org.codejive.twinkle.widgets.graphs.bar.FracBarConfig.*; diff --git a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/MathPlot.java b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/MathPlot.java index c7f4251..2127c97 100644 --- a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/MathPlot.java +++ b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/MathPlot.java @@ -2,8 +2,8 @@ import java.util.function.Function; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.text.Canvas; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.core.widget.Widget; import org.jspecify.annotations.NonNull; diff --git a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/Plot.java b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/Plot.java index aa1b034..a33bc1f 100644 --- a/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/Plot.java +++ b/twinkle-chart/src/main/java/org/codejive/twinkle/widgets/graphs/plot/Plot.java @@ -1,9 +1,9 @@ package org.codejive.twinkle.widgets.graphs.plot; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Panel; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.text.Buffer; +import org.codejive.twinkle.core.text.Canvas; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.core.widget.Widget; import org.jspecify.annotations.NonNull; @@ -49,7 +49,7 @@ public class Plot implements Widget { private static final int dotIndex[] = {1, 2, 4, 7, 6, 13, 14, 8, 9, 11}; public static Plot of(Size size) { - return new Plot(Panel.of(size)); + return new Plot(Buffer.of(size)); } public static Plot of(Canvas canvas) { diff --git a/twinkle-chart/src/test/java/examples/BarDemo.java b/twinkle-chart/src/test/java/examples/BarDemo.java index 19f29ac..e0cbe1f 100644 --- a/twinkle-chart/src/test/java/examples/BarDemo.java +++ b/twinkle-chart/src/test/java/examples/BarDemo.java @@ -1,7 +1,7 @@ package examples; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Panel; +import org.codejive.twinkle.core.text.Buffer; +import org.codejive.twinkle.core.text.Canvas; import org.codejive.twinkle.widgets.graphs.bar.Bar; import org.codejive.twinkle.widgets.graphs.bar.BarConfig; import org.codejive.twinkle.widgets.graphs.bar.FracBarConfig; @@ -19,45 +19,45 @@ public static void main(String[] args) { } private static void printSimpleBar() { - Panel pnl = Panel.of(20, 1); + Buffer buf = Buffer.of(20, 1); Bar b = Bar.bar().setValue(42); - b.render(pnl); - System.out.println(pnl); + b.render(buf); + System.out.println(buf); } private static void printHorizontalBars() { - Panel pnl = Panel.of(20, 4); + Buffer buf = Buffer.of(20, 4); FracBarConfig cfg = FracBarConfig.create(); - renderHorizontal(pnl, cfg); - System.out.println(pnl); + renderHorizontal(buf, cfg); + System.out.println(buf); cfg.direction(BarConfig.Direction.R2L); - renderHorizontal(pnl, cfg); - System.out.println(pnl); + renderHorizontal(buf, cfg); + System.out.println(buf); } - private static void renderHorizontal(Panel pnl, FracBarConfig cfg) { - for (int i = 0; i < pnl.size().height(); i++) { - Canvas v = pnl.view(0, i, 20, 1); + private static void renderHorizontal(Buffer buf, FracBarConfig cfg) { + for (int i = 0; i < buf.size().height(); i++) { + Canvas v = buf.view(0, i, 20, 1); Bar b = new Bar(cfg).setValue(30 + i * 27); b.render(v); } } private static void printVerticalBars() { - Panel pnl = Panel.of(16, 8); + Buffer buf = Buffer.of(16, 8); FracBarConfig cfg = FracBarConfig.create().direction(BarConfig.Direction.B2T); - renderVertical(pnl, cfg); - System.out.println(pnl); + renderVertical(buf, cfg); + System.out.println(buf); cfg.direction(BarConfig.Direction.T2B); - renderVertical(pnl, cfg); - System.out.println(pnl); + renderVertical(buf, cfg); + System.out.println(buf); } - private static void renderVertical(Panel pnl, FracBarConfig cfg) { - for (int i = 0; i < pnl.size().width(); i++) { - Canvas v = pnl.view(i, 0, 1, 8); + private static void renderVertical(Buffer buf, FracBarConfig cfg) { + for (int i = 0; i < buf.size().width(); i++) { + Canvas v = buf.view(i, 0, 1, 8); Bar b = new Bar(cfg).setValue(30 + i * 5.4d); b.render(v); } diff --git a/twinkle-chart/src/test/java/examples/MathPlotColorDemo.java b/twinkle-chart/src/test/java/examples/MathPlotColorDemo.java index 12648df..c5ddffb 100644 --- a/twinkle-chart/src/test/java/examples/MathPlotColorDemo.java +++ b/twinkle-chart/src/test/java/examples/MathPlotColorDemo.java @@ -5,9 +5,9 @@ import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Color; import org.codejive.twinkle.ansi.Style; +import org.codejive.twinkle.core.text.Buffer; import org.codejive.twinkle.core.text.Line; -import org.codejive.twinkle.core.widget.Panel; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.widgets.Framed; import org.codejive.twinkle.widgets.graphs.plot.MathPlot; @@ -15,7 +15,7 @@ public class MathPlotColorDemo { public static void main(String[] args) throws InterruptedException { MathPlot p = MathPlot.of(Size.of(40, 20)).ranges(-2 * Math.PI, 2 * Math.PI, -2.0, 2.0); Framed f = Framed.of(p).title(Line.of(" Interfering Waves ")); - Panel pnl = Panel.of(42, 22); + Buffer buf = Buffer.of(42, 22); System.out.print(Ansi.hideCursor()); try { @@ -68,8 +68,8 @@ public static void main(String[] args) throws InterruptedException { Style.ofFgColor(Color.BasicColor.GREEN)); p.plot(x -> a2 * Math.sin(k2 * x + phase2), Style.ofFgColor(Color.BasicColor.RED)); - f.render(pnl); - System.out.println(pnl.toAnsiString()); + f.render(buf); + System.out.println(buf.toAnsiString()); Thread.sleep(20); } diff --git a/twinkle-chart/src/test/java/examples/MathPlotDemo.java b/twinkle-chart/src/test/java/examples/MathPlotDemo.java index 8019e0f..4a335ae 100644 --- a/twinkle-chart/src/test/java/examples/MathPlotDemo.java +++ b/twinkle-chart/src/test/java/examples/MathPlotDemo.java @@ -3,9 +3,9 @@ import java.util.Random; import org.codejive.twinkle.ansi.Ansi; +import org.codejive.twinkle.core.text.Buffer; import org.codejive.twinkle.core.text.Line; -import org.codejive.twinkle.core.widget.Panel; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.widgets.Framed; import org.codejive.twinkle.widgets.graphs.plot.MathPlot; @@ -13,7 +13,7 @@ public class MathPlotDemo { public static void main(String[] args) throws InterruptedException { MathPlot p = MathPlot.of(Size.of(40, 20)).ranges(-2 * Math.PI, 2 * Math.PI, -2.0, 2.0); Framed f = Framed.of(p).title(Line.of(" Interfering Waves ")); - Panel pnl = Panel.of(42, 22); + Buffer buf = Buffer.of(42, 22); System.out.print(Ansi.hideCursor()); try { @@ -63,8 +63,8 @@ public static void main(String[] args) throws InterruptedException { // plot combined wave p.plot(x -> a1 * Math.sin(k1 * x + phase1) + a2 * Math.sin(k2 * x + phase2)); - f.render(pnl); - System.out.println(pnl); + f.render(buf); + System.out.println(buf); Thread.sleep(20); } diff --git a/twinkle-chart/src/test/java/examples/MathPlotFourDemo.java b/twinkle-chart/src/test/java/examples/MathPlotFourDemo.java index 6ac6930..d3a4070 100644 --- a/twinkle-chart/src/test/java/examples/MathPlotFourDemo.java +++ b/twinkle-chart/src/test/java/examples/MathPlotFourDemo.java @@ -7,10 +7,10 @@ import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Color; import org.codejive.twinkle.ansi.Style; +import org.codejive.twinkle.core.text.Buffer; +import org.codejive.twinkle.core.text.Canvas; import org.codejive.twinkle.core.text.Line; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Panel; -import org.codejive.twinkle.core.widget.Size; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.core.widget.Widget; import org.codejive.twinkle.widgets.Framed; import org.codejive.twinkle.widgets.graphs.plot.MathPlot; @@ -18,22 +18,22 @@ public class MathPlotFourDemo { public static void main(String[] args) throws InterruptedException, IOException { - Panel pnl = Panel.of(60, 40); + Buffer buf = Buffer.of(60, 40); AnimatingMathPlot p1 = new AnimatingMathPlot(Size.of(30, 20), " Interfering Waves 1 "); AnimatingMathPlot p2 = new AnimatingMathPlot(Size.of(30, 20), " Interfering Waves 2 "); AnimatingMathPlot p3 = new AnimatingMathPlot(Size.of(30, 20), " Interfering Waves 3 "); AnimatingMathPlot p4 = new AnimatingMathPlot(Size.of(30, 20), " Interfering Waves 4 "); - Canvas v1 = pnl.view(0, 0, 30, 20); - Canvas v2 = pnl.view(30, 0, 30, 20); - Canvas v3 = pnl.view(0, 20, 30, 20); - Canvas v4 = pnl.view(30, 20, 30, 20); + Canvas v1 = buf.view(0, 0, 30, 20); + Canvas v2 = buf.view(30, 0, 30, 20); + Canvas v3 = buf.view(0, 20, 30, 20); + Canvas v4 = buf.view(30, 20, 30, 20); PrintWriter pout = new PrintWriter(System.out); pout.print(Ansi.hideCursor()); try { for (int i = 0; i < 400; i++) { if (i > 0) { - pout.print(Ansi.cursorMove(Ansi.CURSOR_PREV_LINE, pnl.size().height())); + pout.print(Ansi.cursorMove(Ansi.CURSOR_PREV_LINE, buf.size().height())); } p1.update(); @@ -46,7 +46,7 @@ public static void main(String[] args) throws InterruptedException, IOException p3.render(v3); p4.render(v4); - pnl.toAnsi(pout); + buf.toAnsi(pout); pout.println(); Thread.sleep(20); diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/decor/SimpleBorderRenderer.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/decor/SimpleBorderRenderer.java index 0c47928..d749f8d 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/decor/SimpleBorderRenderer.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/decor/SimpleBorderRenderer.java @@ -1,7 +1,7 @@ package org.codejive.twinkle.core.decor; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; +import org.codejive.twinkle.core.text.Canvas; import org.codejive.twinkle.core.widget.Renderable; public class SimpleBorderRenderer implements Renderable { diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/StyledBufferPanel.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Buffer.java similarity index 62% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/StyledBufferPanel.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/text/Buffer.java index 6ee82f5..c2af447 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/StyledBufferPanel.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Buffer.java @@ -1,27 +1,61 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.text; -import static org.codejive.twinkle.core.text.StyledBuffer.REPLACEMENT_CHAR; +import static org.codejive.twinkle.core.text.LineBuffer.REPLACEMENT_CHAR; import java.io.IOException; -import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.text.StyledBuffer; -import org.codejive.twinkle.core.text.StyledCharSequence; +import org.codejive.twinkle.core.util.Rect; +import org.codejive.twinkle.core.util.Size; +import org.codejive.twinkle.util.Printable; +import org.codejive.twinkle.util.StyledIterator; import org.jspecify.annotations.NonNull; -public class StyledBufferPanel implements Panel { +public interface Buffer extends Canvas, Printable { + + @NonNull Buffer resize(@NonNull Size newSize); + + @Override + default @NonNull View view(int left, int top, int width, int height) { + return view(new Rect(left, top, width, height)); + } + + @Override + @NonNull View view(@NonNull Rect rect); + + static @NonNull Buffer of(int width, int height) { + return of(Size.of(width, height)); + } + + static @NonNull Buffer of(@NonNull Size size) { + return new BufferImpl(size); + } + + static @NonNull Buffer of(@NonNull LineBuffer buffer) { + Rect rect = Rect.of(buffer.length(), 1); + LineBuffer[] lines = new LineBuffer[] {buffer}; + return new BufferImpl(rect, lines); + } + + interface View extends Buffer { + View moveTo(int x, int y); + + View moveBy(int dx, int dy); + } +} + +class BufferImpl implements Buffer { protected @NonNull Rect rect; - protected @NonNull StyledBuffer[] lines; + protected @NonNull LineBuffer[] lines; - public StyledBufferPanel(@NonNull Size size) { + public BufferImpl(@NonNull Size size) { this.rect = Rect.of(size); - this.lines = new StyledBuffer[size.height()]; + this.lines = new LineBuffer[size.height()]; for (int i = 0; i < size.height(); i++) { lines[i] = createBuffer(size.width()); } } - protected StyledBufferPanel(@NonNull Rect rect, @NonNull StyledBuffer[] lines) { + protected BufferImpl(@NonNull Rect rect, @NonNull LineBuffer[] lines) { this.rect = rect; this.lines = lines; } @@ -108,11 +142,8 @@ public int putStringAt(int x, int y, long styleState, @NonNull CharSequence str) } @Override - public int putStringAt(int x, int y, @NonNull StyledCharSequence str) { - if (outside(x, y, str.length())) { - return str.length(); - } - return line(y).putStringAt(applyXOffset(x), str); + public int putStringAt(int x, int y, @NonNull StyledIterator iter) { + return line(y).putStringAt(applyXOffset(x), iter); } @Override @@ -139,11 +170,11 @@ public void copyTo(Canvas canvas, int x, int y) { } @Override - public @NonNull Panel resize(@NonNull Size newSize) { + public @NonNull Buffer resize(@NonNull Size newSize) { if (newSize.equals(size())) { return this; } - StyledBuffer[] newLines = new StyledBuffer[newSize.height()]; + LineBuffer[] newLines = new LineBuffer[newSize.height()]; for (int i = 0; i < newSize.height(); i++) { if (i < lines.length) { newLines[i] = lines[i].resize(newSize.width()); @@ -157,16 +188,16 @@ public void copyTo(Canvas canvas, int x, int y) { return this; } - private @NonNull StyledBuffer createBuffer(int width) { - return StyledBuffer.of(width); + private @NonNull LineBuffer createBuffer(int width) { + return LineBuffer.of(width); } @Override - public @NonNull PanelView view(@NonNull Rect viewRect) { - return new StyledBufferPanelView(this, viewRect, lines); + public Buffer.@NonNull View view(@NonNull Rect viewRect) { + return new BufferViewImpl(this, viewRect, lines); } - private StyledBuffer line(int y) { + private LineBuffer line(int y) { y = applyYOffset(y); return lines[y]; } @@ -206,26 +237,6 @@ public String toString() { return sb.toString(); } - @Override - public @NonNull String toAnsiString() { - // Assuming only single-width characters for capacity estimation - // plus 20 extra for escape codes and newline - int initialCapacity = (size().width() + 20) * size().height(); - StringBuilder sb = new StringBuilder(initialCapacity); - sb.append(Ansi.STYLE_RESET); - try { - return toAnsi(sb, Style.F_UNSTYLED).toString(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public @NonNull Appendable toAnsi(Appendable appendable) throws IOException { - appendable.append(Ansi.STYLE_RESET); - return toAnsi(appendable, Style.F_UNSTYLED); - } - @Override public @NonNull String toAnsiString(long currentStyleState) { // Assuming only single-width characters for capacity estimation @@ -251,54 +262,52 @@ public String toString() { } return appendable; } +} - public static class StyledBufferPanelView extends StyledBufferPanel implements PanelView { - protected final @NonNull StyledBufferPanel parentPanel; +class BufferViewImpl extends BufferImpl implements Buffer.View { + protected final @NonNull BufferImpl parentPanel; - protected StyledBufferPanelView( - @NonNull StyledBufferPanel parentPanel, - @NonNull Rect rect, - @NonNull StyledBuffer[] lines) { - super(rect, lines); - this.parentPanel = parentPanel; - } + protected BufferViewImpl( + @NonNull BufferImpl parentPanel, @NonNull Rect rect, @NonNull LineBuffer[] lines) { + super(rect, lines); + this.parentPanel = parentPanel; + } - @Override - protected @NonNull Rect rect() { - Rect pr = parentPanel.rect(); - return Rect.of( - this.rect.left() + pr.left(), - this.rect.top() + pr.top(), - Math.min( - this.rect.size().width(), - Math.max(0, pr.size().width() - this.rect.left())), - Math.min( - this.rect.size().height(), - Math.max(0, pr.size().height() - this.rect.top()))); - } + @Override + protected @NonNull Rect rect() { + Rect pr = parentPanel.rect(); + return Rect.of( + this.rect.left() + pr.left(), + this.rect.top() + pr.top(), + Math.min( + this.rect.size().width(), + Math.max(0, pr.size().width() - this.rect.left())), + Math.min( + this.rect.size().height(), + Math.max(0, pr.size().height() - this.rect.top()))); + } - @Override - public @NonNull Panel resize(@NonNull Size newSize) { - if (newSize.equals(size())) { - return this; - } - Rect r = rect(); - rect = Rect.of(r.left(), r.top(), newSize); + @Override + public @NonNull Buffer resize(@NonNull Size newSize) { + if (newSize.equals(size())) { return this; } + Rect r = rect(); + rect = Rect.of(r.left(), r.top(), newSize); + return this; + } - @Override - public PanelView moveTo(int x, int y) { - Rect r = rect(); - rect = Rect.of(x, y, r.size()); - return this; - } + @Override + public View moveTo(int x, int y) { + Rect r = rect(); + rect = Rect.of(x, y, r.size()); + return this; + } - @Override - public PanelView moveBy(int dx, int dy) { - Rect r = rect(); - rect = Rect.of(r.left() + dx, r.top() + dy, r.size()); - return this; - } + @Override + public View moveBy(int dx, int dy) { + Rect r = rect(); + rect = Rect.of(r.left() + dx, r.top() + dy, r.size()); + return this; } } diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Canvas.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Canvas.java similarity index 88% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Canvas.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/text/Canvas.java index df54dda..0547328 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Canvas.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Canvas.java @@ -1,7 +1,9 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.text; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.text.StyledCharSequence; +import org.codejive.twinkle.core.util.Rect; +import org.codejive.twinkle.core.util.Sized; +import org.codejive.twinkle.util.StyledIterator; import org.jspecify.annotations.NonNull; public interface Canvas extends Sized { @@ -39,7 +41,7 @@ default int putStringAt(int x, int y, @NonNull Style style, @NonNull CharSequenc int putStringAt(int x, int y, long styleState, @NonNull CharSequence str); - int putStringAt(int x, int y, @NonNull StyledCharSequence str); + int putStringAt(int x, int y, @NonNull StyledIterator iter); default void drawHLineAt(int x, int y, int x2, Style style, char c) { drawHLineAt(x, y, x2, style.state(), c); diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Line.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Line.java index 8e79625..baee037 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Line.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Line.java @@ -1,15 +1,21 @@ package org.codejive.twinkle.core.text; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Renderable; +import org.codejive.twinkle.util.Printable; +import org.codejive.twinkle.util.StyledIterator; +import org.jspecify.annotations.NonNull; -public class Line implements Renderable { +public class Line implements Printable { private final List spans; + public List spans() { + return Collections.unmodifiableList(spans); + } + public static Line of(String text) { return new Line((Span.of(text))); } @@ -31,12 +37,48 @@ protected Line(Span... spans) { Collections.addAll(this.spans, spans); } + public static Line of(StyledIterator iter) { + List spans = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + long currentStyleState = Style.F_UNKNOWN; + while (iter.hasNext()) { + long cp = iter.next(); + if (cp == '\n') { + break; + } + if (iter.styleState() != currentStyleState) { + if (sb.length() > 0) { + spans.add(Span.of(sb.toString(), currentStyleState)); + sb.setLength(0); + } + currentStyleState = iter.styleState(); + } + sb.appendCodePoint((int) cp); + } + if (sb.length() > 0) { + spans.add(Span.of(sb.toString(), currentStyleState)); + } + return new Line(spans.toArray(new Span[0])); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Line{spans=["); + for (Span span : spans) { + sb.append(span.text()).append(", "); + } + sb.append("]}"); + return sb.toString(); + } + @Override - public void render(Canvas canvas) { - int x = 0; + public @NonNull Appendable toAnsi(Appendable appendable, long currentStyleState) + throws IOException { for (Span span : spans) { - span.render(canvas.view(x, 0, span.length(), 1)); - x += span.length(); + span.toAnsi(appendable, currentStyleState); + currentStyleState = span.style().state(); } + return appendable; } } diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCodepointBuffer.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/LineBuffer.java similarity index 58% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCodepointBuffer.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/text/LineBuffer.java index 425a378..ac20834 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCodepointBuffer.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/LineBuffer.java @@ -1,25 +1,91 @@ package org.codejive.twinkle.core.text; import java.io.IOException; -import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Style; +import org.codejive.twinkle.util.Printable; +import org.codejive.twinkle.util.StyledIterator; import org.jspecify.annotations.NonNull; -public class StyledCodepointBuffer implements StyledBuffer { +public interface LineBuffer extends Printable { + + char REPLACEMENT_CHAR = '\uFFFD'; + + int length(); + + char charAt(int index); + + int codepointAt(int i); + + @NonNull String graphemeAt(int i); + + long styleStateAt(int i); + + @NonNull Style styleAt(int i); + + default void setCharAt(int index, @NonNull Style style, char c) { + setCharAt(index, style.state(), c); + } + + void setCharAt(int index, long styleState, char c); + + default void setCharAt(int index, @NonNull Style style, int cp) { + setCharAt(index, style.state(), cp); + } + + void setCharAt(int index, long styleState, int cp); + + default void setCharAt(int index, @NonNull Style style, @NonNull CharSequence grapheme) { + setCharAt(index, style.state(), grapheme); + } + + void setCharAt(int index, long styleState, @NonNull CharSequence grapheme); + + default int putStringAt(int index, @NonNull Style style, @NonNull CharSequence str) { + return putStringAt(index, style.state(), str); + } + + int putStringAt(int index, long styleState, @NonNull CharSequence str); + + int putStringAt(int index, @NonNull StyledIterator iter); + + @NonNull LineBuffer subSequence(int start, int end); + + @NonNull LineBuffer resize(int newSize); + + LineBuffer EMPTY = + new LineBufferImpl(0) { + @Override + public @NonNull LineBufferImpl resize(int newSize) { + if (newSize != 0) { + throw new UnsupportedOperationException("Cannot resize EMPTY"); + } + return this; + } + }; + + static @NonNull LineBuffer of(int width) { + return new LineBufferImpl(width); + } +} + +class LineBufferImpl implements LineBuffer { protected int[] cpBuffer; + protected String[] graphemeBuffer; protected long[] styleBuffer; - public StyledCodepointBuffer(int size) { + public LineBufferImpl(int size) { cpBuffer = new int[size]; + graphemeBuffer = new String[size]; styleBuffer = new long[size]; } - protected StyledCodepointBuffer(int[] cpBuffer, long[] styleBuffer) { - if (cpBuffer.length != styleBuffer.length) { + protected LineBufferImpl(int[] cpBuffer, String[] graphemeBuffer, long[] styleBuffer) { + if (cpBuffer.length != styleBuffer.length || cpBuffer.length != graphemeBuffer.length) { throw new IllegalArgumentException( - "Codepoint buffer and style buffer must have the same length"); + "Codepoint, grapheme and style buffers must have the same length"); } this.cpBuffer = cpBuffer; + this.graphemeBuffer = graphemeBuffer; this.styleBuffer = styleBuffer; } @@ -33,7 +99,7 @@ public char charAt(int index) { if (invalidIndex(index)) { return REPLACEMENT_CHAR; } - if (Character.charCount(cpBuffer[index]) == 2) { + if (graphemeBuffer[index] != null || Character.charCount(cpBuffer[index]) == 2) { // TODO log warning about extended Unicode characters not being supported return REPLACEMENT_CHAR; } @@ -53,6 +119,9 @@ public int codepointAt(int index) { if (invalidIndex(index)) { return String.valueOf(REPLACEMENT_CHAR); } + if (graphemeBuffer[index] != null) { + return graphemeBuffer[index]; + } return new String(Character.toChars(cpBuffer[index])); } @@ -90,6 +159,7 @@ private void setCharAt_(int index, long styleState, char ch) { ch = REPLACEMENT_CHAR; } cpBuffer[index] = ch; + graphemeBuffer[index] = null; styleBuffer[index] = styleState; } @@ -103,6 +173,7 @@ public void setCharAt(int index, long styleState, int cp) { private void setCharAt_(int index, long styleState, int cp) { cpBuffer[index] = cp; + graphemeBuffer[index] = null; styleBuffer[index] = styleState; } @@ -118,82 +189,87 @@ private void setCharAt_(int index, long styleState, @NonNull CharSequence graphe if (grapheme.length() == 0) { return; } - int cp; - if (codepointCount(grapheme) > 1) { - // TODO log warning about extended Unicode graphemes not being supported - cp = REPLACEMENT_CHAR; - } else { - cp = codepointAt(grapheme, 0); - } - cpBuffer[index] = cp; + cpBuffer[index] = -1; + graphemeBuffer[index] = grapheme.toString(); styleBuffer[index] = styleState; } @Override public int putStringAt(int index, long styleState, @NonNull CharSequence str) { - if (outside(index, str.length())) { - return str.length(); - } - // TODO this code can be optimized by avoiding calculating codepointCount - // and simply looping until the end of the char sequence is reached - int cpsCount = codepointCount(str); - int minIndex = 0; - int maxIndex = cpBuffer.length; - int startIndex = Math.max(index, minIndex); - int strStart = Math.max(startIndex - index, 0); - int endIndex = Math.min(index + cpsCount, maxIndex); - int len = endIndex - startIndex; - for (int i = 0; i < len; ) { - int cp = codepointAt(str, strStart + i); - setCharAt_(startIndex + i, styleState, cp); - i += Character.charCount(cp); - } - return cpsCount; + return putStringAt(index, StyledIterator.of(str, styleState)); } @Override - public int putStringAt(int index, @NonNull StyledCharSequence str) { - if (outside(index, str.length())) { - return str.length(); - } + public int putStringAt(int index, @NonNull StyledIterator iter) { int minIndex = 0; int maxIndex = cpBuffer.length; int startIndex = Math.max(index, minIndex); - int endIndex = Math.min(index + str.length(), maxIndex); - int strStart = Math.max(startIndex - index, 0); - int len = endIndex - startIndex; - for (int i = 0; i < len; i++) { - setCharAt_( - startIndex + i, str.styleStateAt(strStart + i), str.codepointAt(strStart + i)); + int len = maxIndex - startIndex; + int cnt = 0; + while (iter.hasNext()) { + int cp = iter.next(); + if (cp == '\n') { + // We only deal with single lines here, so stop at newline + break; + } + if (iter.width() == 0) { + // We shouldn't be getting any of these from a StyledIterator, but just in case... + continue; + } + if (cnt < len) { + long style = iter.styleState(); + if (iter.isComplex()) { + setCharAt_(startIndex + cnt, style, iter.sequence()); + } else { + setCharAt_(startIndex + cnt, style, cp); + } + } + cnt++; + if (iter.width() == 2 && cnt < len) { + // We're dealing with a wide character, so we need to mark the next cell as skipped + setSkipAt(startIndex + cnt); + cnt++; + } } - return str.length(); + return cnt; + } + + private void setSkipAt(int index) { + cpBuffer[index] = -1; + graphemeBuffer[index] = null; + styleBuffer[index] = -1; } @Override - public @NonNull StyledCharSequence subSequence(int start, int end) { + public @NonNull LineBufferImpl subSequence(int start, int end) { if (start < 0 || end > length() || start > end) { throw new IndexOutOfBoundsException( "Invalid subsequence range: " + start + " to " + end); } int subLength = end - start; int[] subCpBuffer = new int[subLength]; + String[] subGraphemeBuffer = new String[subLength]; long[] subStyleBuffer = new long[subLength]; System.arraycopy(cpBuffer, start, subCpBuffer, 0, subLength); + System.arraycopy(graphemeBuffer, start, subGraphemeBuffer, 0, subLength); System.arraycopy(styleBuffer, start, subStyleBuffer, 0, subLength); - return new StyledCodepointBuffer(subCpBuffer, subStyleBuffer); + return new LineBufferImpl(subCpBuffer, subGraphemeBuffer, subStyleBuffer); } @Override - public @NonNull StyledCodepointBuffer resize(int newSize) { + public @NonNull LineBufferImpl resize(int newSize) { if (newSize == cpBuffer.length) { return this; } int[] newCpBuffer = new int[newSize]; + String[] newGraphemeBuffer = new String[newSize]; long[] newStyleBuffer = new long[newSize]; int copyLength = Math.min(newSize, length()); System.arraycopy(cpBuffer, 0, newCpBuffer, 0, copyLength); + System.arraycopy(graphemeBuffer, 0, newGraphemeBuffer, 0, copyLength); System.arraycopy(styleBuffer, 0, newStyleBuffer, 0, copyLength); cpBuffer = newCpBuffer; + graphemeBuffer = newGraphemeBuffer; styleBuffer = newStyleBuffer; return this; } @@ -241,34 +317,19 @@ private boolean outside(int index, int length) { int initialCapacity = length(); StringBuilder sb = new StringBuilder(initialCapacity); for (int i = 0; i < length(); i++) { - int cp = cpBuffer[i]; - if (cp == '\0') { - cp = ' '; + if (graphemeBuffer[i] != null) { + sb.append(graphemeBuffer[i]); + } else { + int cp = cpBuffer[i]; + if (cp == '\0') { + cp = ' '; + } + sb.appendCodePoint(cp); } - sb.appendCodePoint(cp); } return sb.toString(); } - @Override - public @NonNull String toAnsiString() { - // Assuming only single-width characters for capacity estimation - // plus 20 extra for escape codes - int initialCapacity = length() + 20; - StringBuilder sb = new StringBuilder(initialCapacity); - try { - return toAnsi(sb).toString(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public @NonNull Appendable toAnsi(Appendable appendable) throws IOException { - appendable.append(Ansi.STYLE_RESET); - return toAnsi(appendable, Style.UNSTYLED.state()); - } - /** * Converts the buffer to an ANSI string, including ANSI escape codes for styles and colors. * This method takes into account the provided current style to generate a result that is as @@ -304,18 +365,22 @@ private boolean outside(int index, int length) { style.toAnsi(appendable, lastStyleState); lastStyleState = styleBuffer[i]; } - int cp = cpBuffer[i]; - if (cp == '\0') { - cp = ' '; - } - if (Character.isBmpCodePoint(cp)) { - appendable.append((char) cp); - } else if (Character.isValidCodePoint(cp)) { - appendable.append(Character.lowSurrogate(cp)); - appendable.append(Character.highSurrogate(cp)); + if (graphemeBuffer[i] != null) { + appendable.append(graphemeBuffer[i]); } else { - throw new IllegalArgumentException( - String.format("Not a valid Unicode code point: 0x%X", cp)); + int cp = cpBuffer[i]; + if (cp == '\0') { + cp = ' '; + } + if (Character.isBmpCodePoint(cp)) { + appendable.append((char) cp); + } else if (Character.isValidCodePoint(cp)) { + appendable.append(Character.lowSurrogate(cp)); + appendable.append(Character.highSurrogate(cp)); + } else { + throw new IllegalArgumentException( + String.format("Not a valid Unicode code point: 0x%X", cp)); + } } } return appendable; diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Span.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Span.java index 3a4bee2..cb4d770 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Span.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Span.java @@ -1,38 +1,55 @@ package org.codejive.twinkle.core.text; +import java.io.IOException; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Renderable; +import org.codejive.twinkle.util.Printable; +import org.jspecify.annotations.NonNull; -public class Span implements Renderable { - private final String text; - private final long styleState; +public class Span implements Printable { + private final @NonNull String text; + private final Style style; private final int length; - public static Span of(String text) { - return new Span(text, Style.F_UNSTYLED); + public static Span of(@NonNull String text) { + return new Span(text, Style.of(Style.F_UNSTYLED)); } - public static Span of(String text, Style style) { - return new Span(text, style.state()); + public static Span of(@NonNull String text, Style style) { + return new Span(text, style); } - public static Span of(String text, long styleState) { - return new Span(text, styleState); + public static Span of(@NonNull String text, long styleState) { + return new Span(text, Style.of(styleState)); } - protected Span(String text, long styleState) { + protected Span(@NonNull String text, Style style) { this.text = text; - this.styleState = styleState; + this.style = style; this.length = text.codePointCount(0, text.length()); } + public @NonNull String text() { + return text; + } + public int length() { return length; } + public @NonNull Style style() { + return style; + } + + @Override + public String toString() { + return "Span{text='" + text + "', style=" + style + "}"; + } + @Override - public void render(Canvas canvas) { - canvas.putStringAt(0, 0, styleState, text); + public @NonNull Appendable toAnsi(Appendable appendable, long currentStyleState) + throws IOException { + style.toAnsi(appendable, currentStyleState); + appendable.append(text); + return appendable; } } diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledBuffer.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledBuffer.java deleted file mode 100644 index 6be21ad..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledBuffer.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.codejive.twinkle.core.text; - -import org.codejive.twinkle.ansi.Printable; -import org.codejive.twinkle.ansi.Style; -import org.jspecify.annotations.NonNull; - -public interface StyledBuffer extends StyledCharSequence, Printable { - - char REPLACEMENT_CHAR = '\uFFFD'; - - default void setCharAt(int index, @NonNull Style style, char c) { - setCharAt(index, style.state(), c); - } - - void setCharAt(int index, long styleState, char c); - - default void setCharAt(int index, @NonNull Style style, int cp) { - setCharAt(index, style.state(), cp); - } - - void setCharAt(int index, long styleState, int cp); - - default void setCharAt(int index, @NonNull Style style, @NonNull CharSequence grapheme) { - setCharAt(index, style.state(), grapheme); - } - - void setCharAt(int index, long styleState, @NonNull CharSequence grapheme); - - default int putStringAt(int index, @NonNull Style style, @NonNull CharSequence str) { - return putStringAt(index, style.state(), str); - } - - int putStringAt(int index, long styleState, @NonNull CharSequence str); - - int putStringAt(int index, @NonNull StyledCharSequence str); - - @NonNull StyledBuffer resize(int newSize); - - StyledBuffer EMPTY = - new StyledCodepointBuffer(0) { - @Override - public @NonNull StyledCodepointBuffer resize(int newSize) { - if (newSize != 0) { - throw new UnsupportedOperationException("Cannot resize EMPTY"); - } - return this; - } - }; - - static @NonNull StyledBuffer of(int width) { - return new StyledCodepointBuffer(width); - } -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCharSequence.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCharSequence.java deleted file mode 100644 index 844294c..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledCharSequence.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.codejive.twinkle.core.text; - -import org.codejive.twinkle.ansi.Style; -import org.jspecify.annotations.NonNull; - -public interface StyledCharSequence { - - int length(); - - /** - * Returns the {@code char} value at the specified index. In contrast to the original {@link - * CharSequence#charAt(int)} specification, this method never throws an exception and always - * returns a valid character. If the index is out of bounds, it returns the Unicode replacement - * character. - * - * @param index the index of the {@code char} value to be returned - * @return the specified {@code char} value - */ - char charAt(int index); - - int codepointAt(int i); - - @NonNull String graphemeAt(int i); - - long styleStateAt(int i); - - @NonNull Style styleAt(int i); - - // @Override - @NonNull StyledCharSequence subSequence(int start, int end); - - static @NonNull StyledCharSequence fromString(@NonNull Style style, @NonNull String str) { - StyledStringBuilder builder = new StyledStringBuilder(str.length()); - builder.append(style, str); - return builder; - } - - static @NonNull StyledCharSequence fromString(long styleState, @NonNull String str) { - StyledStringBuilder builder = new StyledStringBuilder(str.length()); - builder.append(styleState, str); - return builder; - } -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledStringBuilder.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledStringBuilder.java deleted file mode 100644 index de47983..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/StyledStringBuilder.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.codejive.twinkle.core.text; - -import org.codejive.twinkle.ansi.Style; -import org.jspecify.annotations.NonNull; - -public class StyledStringBuilder implements StyledCharSequence { - private StyledBuffer buffer; - private int length; - - private static final int INITIAL_CAPACITY = 16; - - public static StyledStringBuilder create() { - return new StyledStringBuilder(INITIAL_CAPACITY); - } - - public static StyledStringBuilder create(int initialCapacity) { - return new StyledStringBuilder(initialCapacity); - } - - public static StyledStringBuilder of(Style style, CharSequence str) { - return of(style.state(), str); - } - - public static StyledStringBuilder of(long styleState, CharSequence str) { - StyledStringBuilder builder = new StyledStringBuilder(str.length() + INITIAL_CAPACITY); - builder.append(styleState, str); - return builder; - } - - public static StyledStringBuilder of(StyledCharSequence str) { - StyledStringBuilder builder = new StyledStringBuilder(str.length() + INITIAL_CAPACITY); - builder.append(str); - return builder; - } - - public StyledStringBuilder(int initialCapacity) { - this.buffer = StyledBuffer.of(initialCapacity); - this.length = 0; - } - - public StyledStringBuilder append(StyledCharSequence str) { - ensureCapacity(str.length()); - length += buffer.putStringAt(length, str); - return this; - } - - public StyledStringBuilder append(Style style, CharSequence str) { - ensureCapacity(str.length()); - length += buffer.putStringAt(length, style, str); - return this; - } - - public StyledStringBuilder append(long styleState, CharSequence str) { - ensureCapacity(str.length()); - length += buffer.putStringAt(length, styleState, str); - return this; - } - - public StyledStringBuilder append(Style style, Object obj) { - String str = String.valueOf(obj); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, Object obj) { - String str = String.valueOf(obj); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, char ch) { - String str = String.valueOf(ch); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, char ch) { - String str = String.valueOf(ch); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, long number) { - String str = String.valueOf(number); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, long number) { - String str = String.valueOf(number); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, int number) { - String str = String.valueOf(number); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, int number) { - String str = String.valueOf(number); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, double number) { - String str = String.valueOf(number); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, double number) { - String str = String.valueOf(number); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, float number) { - String str = String.valueOf(number); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, float number) { - String str = String.valueOf(number); - return append(styleState, str); - } - - public StyledStringBuilder append(Style style, boolean bool) { - String str = String.valueOf(bool); - return append(style, str); - } - - public StyledStringBuilder append(long styleState, boolean bool) { - String str = String.valueOf(bool); - return append(styleState, str); - } - - @Override - public int length() { - return length; - } - - @Override - public char charAt(int index) { - return buffer.charAt(index); - } - - @Override - public int codepointAt(int index) { - return buffer.codepointAt(index); - } - - @Override - public @NonNull String graphemeAt(int index) { - return buffer.graphemeAt(index); - } - - @Override - public long styleStateAt(int index) { - return buffer.styleStateAt(index); - } - - @Override - public @NonNull Style styleAt(int index) { - return buffer.styleAt(index); - } - - @Override - public @NonNull StyledCharSequence subSequence(int start, int end) { - return buffer.subSequence(start, end); - } - - private void ensureCapacity(int extraLength) { - int requiredLength = length + extraLength; - if (requiredLength > buffer.length()) { - buffer = buffer.resize(requiredLength + 2 * INITIAL_CAPACITY); - } - } -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Text.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Text.java new file mode 100644 index 0000000..a9db003 --- /dev/null +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/text/Text.java @@ -0,0 +1,71 @@ +package org.codejive.twinkle.core.text; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.codejive.twinkle.ansi.Style; +import org.codejive.twinkle.util.Printable; +import org.codejive.twinkle.util.StyledIterator; +import org.jspecify.annotations.NonNull; + +public class Text implements Printable { + private final List lines; + + public static Text of(String text) { + return new Text(new Line((Span.of(text)))); + } + + public static Text of(String text, Style style) { + return of(text, style.state()); + } + + public static Text of(String text, long styleState) { + return new Text(new Line((Span.of(text, styleState)))); + } + + public static Text of(Line... lines) { + return new Text(lines); + } + + public static Text of(StyledIterator iter) { + List lines = new ArrayList<>(); + while (iter.hasNext()) { + lines.add(Line.of(iter)); + } + return new Text(lines.toArray(new Line[0])); + } + + protected Text(Line... lines) { + this.lines = new ArrayList<>(lines.length); + Collections.addAll(this.lines, lines); + } + + @Override + public @NonNull String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Text{lines=[\n"); + for (Line line : lines) { + sb.append(" ").append(line.toString()).append(",\n"); + } + sb.append("]}"); + return sb.toString(); + } + + @Override + public @NonNull Appendable toAnsi(Appendable appendable, long currentStyleState) + throws IOException { + boolean first = true; + for (Line line : lines) { + if (!first) { + appendable.append('\n'); + } + line.toAnsi(appendable, currentStyleState); + if (!line.spans().isEmpty()) { + currentStyleState = line.spans().get(line.spans().size() - 1).style().state(); + } + first = false; + } + return appendable; + } +} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/FlexRect.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/FlexRect.java similarity index 95% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/FlexRect.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/util/FlexRect.java index dba741a..3dea4ec 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/FlexRect.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/FlexRect.java @@ -1,4 +1,4 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.util; import org.jspecify.annotations.NonNull; diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rect.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Rect.java similarity index 98% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rect.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/util/Rect.java index ac84885..cb9d037 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rect.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Rect.java @@ -1,4 +1,4 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.util; import org.jspecify.annotations.NonNull; diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Size.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Size.java similarity index 96% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Size.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/util/Size.java index b298e48..bbcfd84 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Size.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Size.java @@ -1,4 +1,4 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.util; import java.util.Objects; import org.jspecify.annotations.NonNull; diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Sized.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Sized.java similarity index 69% rename from twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Sized.java rename to twinkle-core/src/main/java/org/codejive/twinkle/core/util/Sized.java index 8be2170..d23615b 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Sized.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/util/Sized.java @@ -1,4 +1,4 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.util; import org.jspecify.annotations.NonNull; diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Panel.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Panel.java deleted file mode 100644 index d8f4799..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Panel.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.codejive.twinkle.core.widget; - -import org.codejive.twinkle.ansi.Printable; -import org.codejive.twinkle.core.text.StyledBuffer; -import org.jspecify.annotations.NonNull; - -public interface Panel extends Canvas, Printable { - - @NonNull Panel resize(@NonNull Size newSize); - - @Override - default @NonNull PanelView view(int left, int top, int width, int height) { - return view(new Rect(left, top, width, height)); - } - - @Override - @NonNull PanelView view(@NonNull Rect rect); - - static @NonNull Panel of(int width, int height) { - return of(Size.of(width, height)); - } - - static @NonNull Panel of(@NonNull Size size) { - return new StyledBufferPanel(size); - } - - static @NonNull Panel of(@NonNull StyledBuffer buffer) { - Rect rect = Rect.of(buffer.length(), 1); - StyledBuffer[] lines = new StyledBuffer[] {buffer}; - return new StyledBufferPanel(rect, lines); - } -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/PanelView.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/PanelView.java deleted file mode 100644 index 85acd08..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/PanelView.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.codejive.twinkle.core.widget; - -public interface PanelView extends Panel { - PanelView moveTo(int x, int y); - - PanelView moveBy(int dx, int dy); -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rectangular.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rectangular.java deleted file mode 100644 index 006728c..0000000 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Rectangular.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.codejive.twinkle.core.widget; - -import org.jspecify.annotations.NonNull; - -public interface Rectangular { - @NonNull Rect rect(); -} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Renderable.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Renderable.java index ea80c46..5017481 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Renderable.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Renderable.java @@ -1,5 +1,7 @@ package org.codejive.twinkle.core.widget; +import org.codejive.twinkle.core.text.Canvas; + public interface Renderable { void render(Canvas canvas); } diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Widget.java b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Widget.java index 289a6a8..1cf36c6 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Widget.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/core/widget/Widget.java @@ -1,3 +1,5 @@ package org.codejive.twinkle.core.widget; +import org.codejive.twinkle.core.util.Sized; + public interface Widget extends Sized, Renderable {} diff --git a/twinkle-core/src/main/java/org/codejive/twinkle/widgets/Framed.java b/twinkle-core/src/main/java/org/codejive/twinkle/widgets/Framed.java index 3da0332..8a63be2 100644 --- a/twinkle-core/src/main/java/org/codejive/twinkle/widgets/Framed.java +++ b/twinkle-core/src/main/java/org/codejive/twinkle/widgets/Framed.java @@ -1,11 +1,12 @@ package org.codejive.twinkle.widgets; +import org.codejive.twinkle.ansi.Style; import org.codejive.twinkle.core.decor.SimpleBorderRenderer; +import org.codejive.twinkle.core.text.Canvas; import org.codejive.twinkle.core.text.Line; -import org.codejive.twinkle.core.widget.Canvas; -import org.codejive.twinkle.core.widget.Rect; +import org.codejive.twinkle.core.util.Rect; +import org.codejive.twinkle.core.util.Size; import org.codejive.twinkle.core.widget.Renderable; -import org.codejive.twinkle.core.widget.Size; import org.codejive.twinkle.core.widget.Widget; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -56,7 +57,8 @@ public void render(Canvas canvas) { borderRenderer.render(canvas); } if (title != null) { - title.render(canvas.view(2, 0, canvas.size().width() - 4, 1)); + Canvas view = canvas.view(2, 0, canvas.size().width() - 4, 1); + view.putStringAt(0, 0, Style.F_UNKNOWN, title.toAnsiString()); } if (widget != null) { widget.render(canvas.view(Rect.of(canvas.size()).grow(-1, -1, -1, -1))); diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/StyledBufferTimings.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/LineBufferTimings.java similarity index 91% rename from twinkle-core/src/test/java/org/codejive/twinkle/core/text/StyledBufferTimings.java rename to twinkle-core/src/test/java/org/codejive/twinkle/core/text/LineBufferTimings.java index 225735f..d9e0604 100644 --- a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/StyledBufferTimings.java +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/LineBufferTimings.java @@ -1,6 +1,6 @@ package org.codejive.twinkle.core.text; -public class StyledBufferTimings { +public class LineBufferTimings { private static int iterations = 1_000_000; public static void main(String[] args) { @@ -39,13 +39,13 @@ public static void main(String[] args) { }); System.out.println("Timing simple strings:"); - timeSimpleString(StyledBuffer.of(1000)); + timeSimpleString(LineBuffer.of(1000)); System.out.println("Timing strings with surrogates:"); - timeStringWithSurrogates(StyledBuffer.of(1000)); + timeStringWithSurrogates(LineBuffer.of(1000)); } - private static void timeSimpleString(StyledBuffer buffer) { + private static void timeSimpleString(LineBuffer buffer) { titer( buffer.getClass().getSimpleName(), () -> { @@ -58,7 +58,7 @@ private static void timeSimpleString(StyledBuffer buffer) { }); } - private static void timeStringWithSurrogates(StyledBuffer buffer) { + private static void timeStringWithSurrogates(LineBuffer buffer) { titer( buffer.getClass().getSimpleName(), () -> { diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/widget/TestPanel.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestBuffer.java similarity index 65% rename from twinkle-core/src/test/java/org/codejive/twinkle/core/widget/TestPanel.java rename to twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestBuffer.java index ca8d856..f3465b7 100644 --- a/twinkle-core/src/test/java/org/codejive/twinkle/core/widget/TestPanel.java +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestBuffer.java @@ -1,70 +1,70 @@ -package org.codejive.twinkle.core.widget; +package org.codejive.twinkle.core.text; import static org.assertj.core.api.Assertions.assertThat; import org.codejive.twinkle.ansi.Color; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.text.StyledBuffer; +import org.codejive.twinkle.core.util.Size; import org.junit.jupiter.api.Test; -public class TestPanel { +public class TestBuffer { @Test public void testPanelCreation() { - Panel panel = Panel.of(10, 5); - Size size = panel.size(); + Buffer buffer = Buffer.of(10, 5); + Size size = buffer.size(); assertThat(size.width()).isEqualTo(10); assertThat(size.height()).isEqualTo(5); } @Test public void testPanelDefaultInnerContent() { - Panel panel = Panel.of(10, 5); - Size size = panel.size(); + Buffer buffer = Buffer.of(10, 5); + Size size = buffer.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { - assertThat(panel.charAt(x, y)).isEqualTo('\0'); - assertThat(panel.styleAt(x, y)).isEqualTo(Style.UNSTYLED); + assertThat(buffer.charAt(x, y)).isEqualTo('\0'); + assertThat(buffer.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } } @Test public void testPanelDefaultOuterContent() { - Panel panel = Panel.of(10, 5); - Size size = panel.size(); + Buffer buffer = Buffer.of(10, 5); + Size size = buffer.size(); for (int y = -5; y < size.height() + 5; y++) { for (int x = -5; x < size.width() + 5; x++) { if (x >= 0 && x < size.width() && y >= 0 && y < size.height()) { continue; // Skip inner content } - assertThat(panel.charAt(x, y)).isEqualTo(StyledBuffer.REPLACEMENT_CHAR); - assertThat(panel.styleAt(x, y)).isEqualTo(Style.UNSTYLED); + assertThat(buffer.charAt(x, y)).isEqualTo(LineBuffer.REPLACEMENT_CHAR); + assertThat(buffer.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } } @Test public void testPanelNewContents() { - Panel panel = createPanel(); - Size size = panel.size(); + Buffer buffer = createPanel(); + Size size = buffer.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { - assertThat(panel.charAt(x, y)).isEqualTo((char) ('A' + x + y * size.width())); - assertThat(panel.styleAt(x, y)).isEqualTo(Style.ofFgColor(Color.indexed(x))); + assertThat(buffer.charAt(x, y)).isEqualTo((char) ('A' + x + y * size.width())); + assertThat(buffer.styleAt(x, y)).isEqualTo(Style.ofFgColor(Color.indexed(x))); } } } @Test public void testPanelView() { - Panel panel = createPanel(); - Canvas view = panel.view(1, 1, 3, 3); + Buffer buffer = createPanel(); + Canvas view = buffer.view(1, 1, 3, 3); Size size = view.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { assertThat(view.charAt(x, y)) - .isEqualTo((char) ('G' + x + y * panel.size().width())); + .isEqualTo((char) ('G' + x + y * buffer.size().width())); assertThat(view.styleAt(x, y)).isEqualTo(Style.ofFgColor(Color.indexed(x + 1))); } } @@ -72,15 +72,15 @@ public void testPanelView() { @Test public void testPanelViewOutside() { - Panel panel = createPanel(); - Canvas view = panel.view(1, 1, 3, 3); + Buffer buffer = createPanel(); + Canvas view = buffer.view(1, 1, 3, 3); Size size = view.size(); for (int y = -2; y < size.height() + 2; y++) { for (int x = -2; x < size.width() + 2; x++) { if (x >= 0 && x < size.width() && y >= 0 && y < size.height()) { continue; // Skip inner content } - assertThat(view.charAt(x, y)).isEqualTo(StyledBuffer.REPLACEMENT_CHAR); + assertThat(view.charAt(x, y)).isEqualTo(LineBuffer.REPLACEMENT_CHAR); assertThat(view.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } @@ -88,14 +88,14 @@ public void testPanelViewOutside() { @Test public void testPanelNestedView() { - Panel panel = createPanel(); - Panel view1 = panel.view(1, 1, 3, 3); - Panel view2 = view1.view(1, 1, 2, 2); + Buffer buffer = createPanel(); + Buffer view1 = buffer.view(1, 1, 3, 3); + Buffer view2 = view1.view(1, 1, 2, 2); Size size = view2.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { assertThat(view2.charAt(x, y)) - .isEqualTo((char) ('M' + x + y * panel.size().width())); + .isEqualTo((char) ('M' + x + y * buffer.size().width())); assertThat(view2.styleAt(x, y)).isEqualTo(Style.ofFgColor(Color.indexed(x + 2))); } } @@ -103,16 +103,16 @@ public void testPanelNestedView() { @Test public void testPanelNestedViewOutside() { - Panel panel = createPanel(); - Panel view1 = panel.view(1, 1, 3, 3); - Panel view2 = view1.view(1, 1, 2, 2); + Buffer buffer = createPanel(); + Buffer view1 = buffer.view(1, 1, 3, 3); + Buffer view2 = view1.view(1, 1, 2, 2); Size size = view2.size(); for (int y = -2; y < size.height() + 2; y++) { for (int x = -2; x < size.width() + 2; x++) { if (x >= 0 && x < size.width() && y >= 0 && y < size.height()) { continue; // Skip inner content } - assertThat(view2.charAt(x, y)).isEqualTo(StyledBuffer.REPLACEMENT_CHAR); + assertThat(view2.charAt(x, y)).isEqualTo(LineBuffer.REPLACEMENT_CHAR); assertThat(view2.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } @@ -120,9 +120,9 @@ public void testPanelNestedViewOutside() { @Test public void testPanelNestedViewMoved() { - Panel panel = createPanel(); - PanelView view1 = panel.view(1, 1, 3, 3); - Panel view2 = view1.view(1, 1, 2, 2); + Buffer buffer = createPanel(); + Buffer.View view1 = buffer.view(1, 1, 3, 3); + Buffer view2 = view1.view(1, 1, 2, 2); view1.moveBy(1, 1); @@ -130,7 +130,7 @@ public void testPanelNestedViewMoved() { for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { assertThat(view2.charAt(x, y)) - .isEqualTo((char) ('S' + x + y * panel.size().width())); + .isEqualTo((char) ('S' + x + y * buffer.size().width())); assertThat(view2.styleAt(x, y)).isEqualTo(Style.ofFgColor(Color.indexed(x + 3))); } } @@ -138,16 +138,16 @@ public void testPanelNestedViewMoved() { @Test public void testPanelNestedViewMovedFullyOutside() { - Panel panel = createPanel(); - PanelView view1 = panel.view(1, 1, 3, 3); - Panel view2 = view1.view(1, 1, 2, 2); + Buffer buffer = createPanel(); + Buffer.View view1 = buffer.view(1, 1, 3, 3); + Buffer view2 = view1.view(1, 1, 2, 2); view1.moveBy(10, 10); Size size = view2.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { - assertThat(view2.charAt(x, y)).isEqualTo(StyledBuffer.REPLACEMENT_CHAR); + assertThat(view2.charAt(x, y)).isEqualTo(LineBuffer.REPLACEMENT_CHAR); assertThat(view2.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } @@ -155,9 +155,9 @@ public void testPanelNestedViewMovedFullyOutside() { @Test public void testPanelNestedViewMovedPartiallyOutside() { - Panel panel = createPanel(); - PanelView view1 = panel.view(1, 1, 3, 3); - Panel view2 = view1.view(1, 1, 2, 2); + Buffer buffer = createPanel(); + Buffer.View view1 = buffer.view(1, 1, 3, 3); + Buffer view2 = view1.view(1, 1, 2, 2); view1.moveTo(3, 3); @@ -169,26 +169,26 @@ public void testPanelNestedViewMovedPartiallyOutside() { assertThat(view2.styleAt(x, y)) .isEqualTo(Style.ofFgColor(Color.indexed(x + 4))); } else { - assertThat(view2.charAt(x, y)).isEqualTo(StyledBuffer.REPLACEMENT_CHAR); + assertThat(view2.charAt(x, y)).isEqualTo(LineBuffer.REPLACEMENT_CHAR); assertThat(view2.styleAt(x, y)).isEqualTo(Style.UNSTYLED); } } } } - private Panel createPanel() { - Panel panel = Panel.of(5, 5); - Size size = panel.size(); + private Buffer createPanel() { + Buffer buffer = Buffer.of(5, 5); + Size size = buffer.size(); for (int y = 0; y < size.height(); y++) { for (int x = 0; x < size.width(); x++) { - panel.setCharAt( + buffer.setCharAt( x, y, Style.ofFgColor(Color.indexed(x)), (char) ('A' + x + y * size.width())); } } - return panel; + return buffer; } private void printCanvas(Canvas canvas) { diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLine.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLine.java index 25ce872..651ca1a 100644 --- a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLine.java +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLine.java @@ -4,28 +4,22 @@ import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Panel; +import org.codejive.twinkle.util.StyledIterator; import org.junit.jupiter.api.Test; public class TestLine { @Test public void testRenderSingleStyledSpan() { - Panel p = Panel.of(1, 1); - Line.of("A", Style.BOLD).render(p); - - assertThat(p.toString()).isEqualTo("A"); - assertThat(p.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.BOLD) + "A"); + Line l = Line.of("A", Style.BOLD); + assertThat(l.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.BOLD) + "A"); } @Test public void testRenderMultipleSpans() { - Panel p = Panel.of(3, 1); - Line.of(Span.of("A"), Span.of("B", Style.BOLD), Span.of("C")).render(p); - - assertThat(p.toString()).isEqualTo("ABC"); + Line l = Line.of(Span.of("A"), Span.of("B", Style.BOLD), Span.of("C")); - String ansi = p.toAnsiString(); + String ansi = l.toAnsiString(); assertThat(ansi) .isEqualTo( Ansi.STYLE_RESET @@ -35,4 +29,11 @@ public void testRenderMultipleSpans() { + Ansi.style(Ansi.NORMAL) + "C"); } + + @Test + public void testOfStyledIterator() { + StyledIterator iter = StyledIterator.of("Line 1"); + Line l = Line.of(iter); + assertThat(l.toAnsiString()).isEqualTo("Line 1"); + } } diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestStyledBuffer.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLineBuffer.java similarity index 73% rename from twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestStyledBuffer.java rename to twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLineBuffer.java index 49ac054..0bff2a6 100644 --- a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestStyledBuffer.java +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestLineBuffer.java @@ -6,16 +6,16 @@ import org.codejive.twinkle.ansi.Style; import org.junit.jupiter.api.Test; -public class TestStyledBuffer { +public class TestLineBuffer { @Test public void testStyledBufferCreation() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); assertThat(buffer.length()).isEqualTo(10); } @Test public void testStyledBufferPutGetChar() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); for (int i = 0; i < buffer.length(); i++) { buffer.setCharAt(i, Style.ITALIC.state(), (char) ('a' + i)); } @@ -27,7 +27,7 @@ public void testStyledBufferPutGetChar() { @Test public void testStyledBufferPutCharToString() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); for (int i = 0; i < buffer.length(); i++) { buffer.setCharAt(i, Style.ITALIC.state(), (char) ('a' + i)); } @@ -36,7 +36,7 @@ public void testStyledBufferPutCharToString() { @Test public void testStyledBufferPutCharToAnsiString() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); for (int i = 0; i < buffer.length(); i++) { Style style = i < 5 ? Style.ITALIC : Style.UNDERLINED; buffer.setCharAt(i, style, (char) ('a' + i)); @@ -52,7 +52,7 @@ public void testStyledBufferPutCharToAnsiString() { @Test public void testStyledBufferPutCharToAnsiStringWithCurrentStyle() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); for (int i = 0; i < buffer.length(); i++) { Style style = i < 5 ? Style.ITALIC : Style.UNDERLINED; buffer.setCharAt(i, style, (char) ('a' + i)); @@ -63,7 +63,7 @@ public void testStyledBufferPutCharToAnsiStringWithCurrentStyle() { @Test public void testStyledBufferPutCharToAnsiStringWithUnderAndOverflow() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); for (int i = 0; i < buffer.length() + 10; i++) { Style style = i < 10 ? Style.ITALIC : Style.UNDERLINED; buffer.setCharAt(i - 5, style, (char) ('a' + i)); @@ -79,27 +79,11 @@ public void testStyledBufferPutCharToAnsiStringWithUnderAndOverflow() { @Test public void testStyledBufferPutStringGetChar() { - StyledBuffer buffer = StyledBuffer.of(10); + LineBuffer buffer = LineBuffer.of(10); buffer.putStringAt(0, Style.ITALIC, "abcdefghij"); for (int i = 0; i < buffer.length(); i++) { assertThat(buffer.charAt(i)).isEqualTo((char) ('a' + i)); assertThat(buffer.styleStateAt(i)).isEqualTo(Style.ITALIC.state()); } } - - @Test - public void testStyledBufferPutStyledString() { - StyledBuffer buffer = StyledBuffer.of(10); - buffer.putStringAt(0, StyledStringBuilder.of(Style.ITALIC, "abcdefghij")); - assertThat(buffer.toAnsiString()) - .isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.ITALICIZED) + "abcdefghij"); - } - - @Test - public void testStyledBufferPutStyledStringWithUnderAndOverflow() { - StyledBuffer buffer = StyledBuffer.of(10); - buffer.putStringAt(-5, StyledStringBuilder.of(Style.ITALIC, "xxxxxabcdefghijxxxxx")); - assertThat(buffer.toAnsiString()) - .isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.ITALICIZED) + "abcdefghij"); - } } diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestSpan.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestSpan.java index 0d182c7..a86976e 100644 --- a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestSpan.java +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestSpan.java @@ -4,7 +4,6 @@ import org.codejive.twinkle.ansi.Ansi; import org.codejive.twinkle.ansi.Style; -import org.codejive.twinkle.core.widget.Panel; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -32,9 +31,7 @@ public void testLengthZwjSequence() { @Test public void testSpansRender() { - Panel p = Panel.of(1, 1); - Span.of("A", Style.BOLD).render(p); - assertThat(p.toString()).isEqualTo("A"); - assertThat(p.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.BOLD) + "A"); + Span s = Span.of("A", Style.BOLD); + assertThat(s.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + Ansi.style(Ansi.BOLD) + "A"); } } diff --git a/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestText.java b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestText.java new file mode 100644 index 0000000..73e2de6 --- /dev/null +++ b/twinkle-core/src/test/java/org/codejive/twinkle/core/text/TestText.java @@ -0,0 +1,48 @@ +package org.codejive.twinkle.core.text; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.codejive.twinkle.ansi.Ansi; +import org.codejive.twinkle.ansi.Style; +import org.codejive.twinkle.util.StyledIterator; +import org.junit.jupiter.api.Test; + +public class TestText { + + @Test + public void testOfSimpleString() { + Text t = Text.of("Hello World"); + assertThat(t.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + "Hello World"); + } + + @Test + public void testOfStyledString() { + Style style = Style.BOLD; + Text t = Text.of("Hello World", style); + assertThat(t.toAnsiString(Style.F_UNSTYLED)) + .isEqualTo(style.toAnsiString() + "Hello World"); + } + + @Test + public void testOfStyleState() { + Style style = Style.BOLD; + Text t = Text.of("Hello World", style.state()); + assertThat(t.toAnsiString(Style.F_UNSTYLED)) + .isEqualTo(style.toAnsiString() + "Hello World"); + } + + @Test + public void testOfLines() { + Line line1 = Line.of("Line 1"); + Line line2 = Line.of("Line 2"); + Text t = Text.of(line1, line2); + assertThat(t.toAnsiString()).isEqualTo(Ansi.STYLE_RESET + "Line 1\nLine 2"); + } + + @Test + public void testOfStyledIterator() { + StyledIterator iter = StyledIterator.of("Line 1\nLine 2"); + Text t = Text.of(iter); + assertThat(t.toAnsiString()).isEqualTo("Line 1\nLine 2"); + } +}