From 5276540c72f9dc0817ce79bc49debe1cebc11764 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Mon, 21 Sep 2026 19:32:10 +0200 Subject: [PATCH] feat: now model basic and indexed colors As well as the existing rgb colors --- .../codejive/miniterm/ansiparser/Ansi.java | 43 +- colors/README.md | 14 +- .../org/codejive/miniterm/colors/Color.java | 716 ++++++++++++++---- .../codejive/miniterm/colors/TermColors.java | 77 +- .../codejive/miniterm/colors/ColorTest.java | 79 +- .../miniterm/colors/TermColorsTest.java | 71 +- examples/PrintColors.java | 120 ++- 7 files changed, 819 insertions(+), 301 deletions(-) diff --git a/ansiparser/src/main/java/org/codejive/miniterm/ansiparser/Ansi.java b/ansiparser/src/main/java/org/codejive/miniterm/ansiparser/Ansi.java index 42b2efe..b1610b3 100644 --- a/ansiparser/src/main/java/org/codejive/miniterm/ansiparser/Ansi.java +++ b/ansiparser/src/main/java/org/codejive/miniterm/ansiparser/Ansi.java @@ -13,6 +13,27 @@ public class Ansi { public static final String DEC_SET = "h"; public static final String DEC_RST = "l"; + // ANSI style codes + public static final int STYLE_RESET = 0; + public static final int STYLE_BOLD = 1; + public static final int STYLE_FAINT = 2; + public static final int STYLE_ITALIC = 3; + public static final int STYLE_UNDERLINE = 4; + public static final int STYLE_BLINK = 5; + public static final int STYLE_INVERSE = 7; + public static final int STYLE_HIDDEN = 8; + public static final int STYLE_STRIKETHROUGH = 9; + public static final int STYLE_DOUBLE_UNDERLINE = 21; + public static final int STYLE_NORMAL = 22; + public static final int STYLE_ITALIC_OFF = 23; + public static final int STYLE_UNDERLINE_OFF = 24; + public static final int STYLE_BLINK_OFF = 25; + public static final int STYLE_INVERSE_OFF = 27; + public static final int STYLE_HIDDEN_OFF = 28; + public static final int STYLE_STRIKETHROUGH_OFF = 29; + public static final int STYLE_OVERLINE = 53; + public static final int STYLE_OVERLINE_OFF = 55; + // Positive mode numbers are official ANSI modes public static final int MODE_ECHO = 12; @@ -37,11 +58,11 @@ public class Ansi { public static final int MODE_UNICODE = -2027; public static final int MODE_SYSTEMTHEME = -2031; - public static String csi(String post, int... params) { + public static String csi(String post, Object... params) { return csi((String) null, post, params); } - public static String csi(String pre, String post, int... params) { + public static String csi(String pre, String post, Object... params) { try { return csi(new StringBuilder(), pre, post, params).toString(); } catch (IOException e) { @@ -49,12 +70,12 @@ public static String csi(String pre, String post, int... params) { } } - public static Appendable csi(Appendable appendable, String post, int... params) + public static Appendable csi(Appendable appendable, String post, Object... params) throws IOException { return csi(appendable, null, post, params); } - public static Appendable csi(Appendable appendable, String pre, String post, int... params) + public static Appendable csi(Appendable appendable, String pre, String post, Object... params) throws IOException { appendable.append(CSI); if (pre != null) { @@ -62,7 +83,7 @@ public static Appendable csi(Appendable appendable, String pre, String post, int } for (int i = 0; i < params.length; i++) { if (i > 0) appendable.append(";"); - appendable.append(Integer.toString(params[i])); + appendable.append(params[i].toString()); } if (post != null) { appendable.append(post); @@ -70,6 +91,18 @@ public static Appendable csi(Appendable appendable, String pre, String post, int return appendable; } + public static String style(Object... params) { + return csi("", "m", params); + } + + public static String resetStyle() { + return style(STYLE_RESET); + } + + public static String styled(String text, Object... params) { + return style(params) + text + resetStyle(); + } + public static String modeQuery(int mode) { if (mode >= 0) { return csi("", "$p", mode); diff --git a/colors/README.md b/colors/README.md index 50eb611..773fc01 100644 --- a/colors/README.md +++ b/colors/README.md @@ -48,11 +48,11 @@ Color[] result = TermColors.queryPalette(terminal, in, ansi16); ### Set colours ```java -TermColors.setForeground(terminal, Color.ofRgb8(220, 220, 220)); -TermColors.setBackground(terminal, Color.ofRgb8(30, 30, 30)); -TermColors.setCursor(terminal, Color.ofRgb8(255, 165, 0)); +TermColors.setForeground(terminal, Color.rgb8(220, 220, 220)); +TermColors.setBackground(terminal, Color.rgb8(30, 30, 30)); +TermColors.setCursor(terminal, Color.rgb8(255, 165, 0)); -TermColors.setColor(terminal, 1, Color.ofRgb8(204, 0, 0)); // redefine ANSI red +TermColors.setColor(terminal, 1, Color.rgb8(204, 0, 0)); // redefine ANSI red ``` Set the full palette from an array (null entries are skipped): @@ -99,9 +99,9 @@ IntReader in = () -> terminal.read(500); // 500 ms timeout per read An immutable 16-bit-per-channel RGB colour, matching the precision used in the X11 `rgb:` colour specification that terminals return in OSC responses. ```java -Color red = Color.of(0xFFFF, 0x0000, 0x0000); // 16-bit channels -Color green = Color.ofRgb8(0, 255, 0); // 8-bit, expanded -Color blue = Color.parse("rgb:0000/0000/FFFF"); // from OSC string +Color red = Color.rgb(0xFFFF, 0x0000, 0x0000); // 16-bit channels +Color green = Color.rgb8(0, 255, 0); // 8-bit, expanded +Color blue = Color.RgbColor.parse("rgb:0000/0000/FFFF"); // from OSC string ``` Conversion helpers: diff --git a/colors/src/main/java/org/codejive/miniterm/colors/Color.java b/colors/src/main/java/org/codejive/miniterm/colors/Color.java index 16102c7..aadeeb8 100644 --- a/colors/src/main/java/org/codejive/miniterm/colors/Color.java +++ b/colors/src/main/java/org/codejive/miniterm/colors/Color.java @@ -1,195 +1,589 @@ package org.codejive.miniterm.colors; -/** - * An immutable 16-bit-per-channel RGB colour, as used in terminal OSC colour specifications. - * - *

Colours are stored with 16 bits per channel (0–65535), which matches the precision returned by - * terminals in {@code rgb:RRRR/GGGG/BBBB} format. 8-bit values (0–255) can be created with {@link - * #ofRgb8} and retrieved with {@link #r8()}, {@link #g8()}, {@link #b8()}. - * - *

Parsing

- * - *

{@link #parse(String)} accepts the X11 {@code rgb:} colour specification with 1–4 hex digits - * per channel: - * - *

{@code
- * Color red = Color.parse("rgb:FFFF/0000/0000");
- * Color grey = Color.parse("rgb:80/80/80"); // 8-bit
- * }
- * - *

Formatting

- * - *

{@link #toString()} always produces the canonical 4-digit form {@code rgb:RRRR/GGGG/BBBB}, - * suitable for use in OSC set sequences. - */ -public final class Color { - - private final int r; // 0..65535 - private final int g; - private final int b; - - private Color(int r, int g, int b) { - if (r < 0 || r > 65535 || g < 0 || g > 65535 || b < 0 || b > 65535) { - throw new IllegalArgumentException( - "colour component out of range [0..65535]: " + r + "," + g + "," + b); - } - this.r = r; - this.g = g; - this.b = b; - } +import java.util.Objects; +import org.codejive.miniterm.ansiparser.Ansi; - /** - * Creates a colour from 16-bit per channel values (0–65535 each). - * - * @param r red channel 0–65535 - * @param g green channel 0–65535 - * @param b blue channel 0–65535 - * @return new colour - * @throws IllegalArgumentException if any component is out of range - */ - public static Color of(int r, int g, int b) { - return new Color(r, g, b); - } +public interface Color { - /** - * Creates a colour from 8-bit per channel values (0–255 each). - * - *

Each component is expanded to 16 bits by byte-replication ({@code 0xFF → 0xFFFF}). - * - * @param r red channel 0–255 - * @param g green channel 0–255 - * @param b blue channel 0–255 - * @return new colour - * @throws IllegalArgumentException if any component is out of range - */ - public static Color ofRgb8(int r, int g, int b) { - if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { - throw new IllegalArgumentException( - "8-bit colour component out of range [0..255]: " + r + "," + g + "," + b); - } - return new Color((r << 8) | r, (g << 8) | g, (b << 8) | b); - } + public static final int COLOR_IDX_BLACK = 0; + public static final int COLOR_IDX_RED = 1; + public static final int COLOR_IDX_GREEN = 2; + public static final int COLOR_IDX_YELLOW = 3; + public static final int COLOR_IDX_BLUE = 4; + public static final int COLOR_IDX_MAGENTA = 5; + public static final int COLOR_IDX_CYAN = 6; + public static final int COLOR_IDX_WHITE = 7; - /** - * Parses an X11 {@code rgb:} colour specification. - * - *

Accepts 1–4 hex digits per channel. Each component is scaled to 16 bits: - * - *

- * - * @param s string starting with {@code rgb:} followed by three hex components separated by - * {@code /} - * @return parsed colour, or {@code null} if {@code s} is null, does not start with {@code - * rgb:}, or contains invalid hex digits - */ - public static Color parse(String s) { - if (s == null) return null; - String t = s.trim(); - if (!t.startsWith("rgb:")) return null; - String[] parts = t.substring(4).split("/", -1); - if (parts.length != 3) return null; - try { - int r = normalizeHex(parts[0].trim()); - int g = normalizeHex(parts[1].trim()); - int b = normalizeHex(parts[2].trim()); - return new Color(r, g, b); - } catch (IllegalArgumentException e) { - return null; - } - } + public static final int STYLE_FOREGROUND = 38; + public static final int STYLE_DEFAULT_FOREGROUND = 39; + public static final int STYLE_BACKGROUND = 48; + public static final int STYLE_DEFAULT_BACKGROUND = 49; + public static final int STYLE_RGB = 2; + public static final int STYLE_INDEXED = 5; + + Color DEFAULT = DefaultColor.instance(); - /** Red channel as a 16-bit value (0–65535). */ - public int r() { - return r; + static BasicColor basic(int index) { + return BasicColor.byIndex(index); } - /** Green channel as a 16-bit value (0–65535). */ - public int g() { - return g; + static BasicColor basic(int index, BasicColor.Intensity intensity) { + return BasicColor.byIndex(index, intensity); } - /** Blue channel as a 16-bit value (0–65535). */ - public int b() { - return b; + static IndexedColor indexed(int index) { + return IndexedColor.of(index); } - /** Red channel downsampled to 8 bits (0–255). */ - public int r8() { - return r >> 8; + static RgbColor rgb(int r, int g, int b) { + return RgbColor.of(r, g, b); } - /** Green channel downsampled to 8 bits (0–255). */ - public int g8() { - return g >> 8; + static RgbColor rgb8(int r, int g, int b) { + return RgbColor.ofRgb8(r, g, b); } - /** Blue channel downsampled to 8 bits (0–255). */ - public int b8() { - return b >> 8; + static String styled(String text, Color foreground, Color background) { + Color fg = foreground != null ? foreground : DefaultColor.instance(); + Color bg = background != null ? background : DefaultColor.instance(); + return Ansi.style(fg.toForegroundStyle()) + + Ansi.style(bg.toBackgroundStyle()) + + text + + Ansi.style(DefaultColor.instance().toForegroundStyle()) + + Ansi.style(DefaultColor.instance().toBackgroundStyle()); } /** - * Returns the colour as a packed 24-bit RGB integer ({@code 0xRRGGBB}), using the 8-bit - * downsampled values. + * Convert this color to ANSI escape code for setting the foreground color to the color + * represented by this instance. This is NOT the full CSI sequence, to get the full sequence + * pass the result to Ansi.style(). * - * @return packed RGB integer + * @return Embeddable ANSI escape code string */ - public int toRgb8() { - return (r8() << 16) | (g8() << 8) | b8(); - } + String toForegroundStyle(); /** - * Returns the canonical {@code rgb:RRRR/GGGG/BBBB} representation suitable for use in OSC - * terminal sequences. + * Convert this color to ANSI escape code for setting the background color to the color + * represented by this instance. This is NOT the full CSI sequence, to get the full sequence + * pass the result to Ansi.style(). + * + * @return Embeddable ANSI escape code string */ - @Override - public String toString() { - return String.format("rgb:%04X/%04X/%04X", r, g, b); + String toBackgroundStyle(); + + class DefaultColor implements Color { + private static final DefaultColor INSTANCE = new DefaultColor(); + + private DefaultColor() {} + + protected static Color instance() { + return INSTANCE; + } + + @Override + public String toForegroundStyle() { + return String.valueOf(STYLE_DEFAULT_FOREGROUND); + } + + @Override + public String toBackgroundStyle() { + return String.valueOf(STYLE_DEFAULT_BACKGROUND); + } + + @Override + public String toString() { + return "default"; + } } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Color)) return false; - Color other = (Color) o; - return r == other.r && g == other.g && b == other.b; + class BasicColor implements Color { + private final String name; + private final int index; + private final Intensity intensity; + private final String fgStyle; + private final String bgStyle; + + public enum Intensity { + normal, + dark, + bright; + } + + public static final BasicColor black = + BasicColor.of("black", COLOR_IDX_BLACK, Intensity.normal); + public static final BasicColor red = BasicColor.of("red", COLOR_IDX_RED, Intensity.normal); + public static final BasicColor green = + BasicColor.of("green", COLOR_IDX_GREEN, Intensity.normal); + public static final BasicColor yellow = + BasicColor.of("yellow", COLOR_IDX_YELLOW, Intensity.normal); + public static final BasicColor blue = + BasicColor.of("blue", COLOR_IDX_BLUE, Intensity.normal); + public static final BasicColor magenta = + BasicColor.of("magenta", COLOR_IDX_MAGENTA, Intensity.normal); + public static final BasicColor cyan = + BasicColor.of("cyan", COLOR_IDX_CYAN, Intensity.normal); + public static final BasicColor white = + BasicColor.of("white", COLOR_IDX_WHITE, Intensity.normal); + + public static final BasicColor darkBlack = + BasicColor.of("black", COLOR_IDX_BLACK, Intensity.dark); + public static final BasicColor darkRed = + BasicColor.of("red", COLOR_IDX_RED, Intensity.dark); + public static final BasicColor darkGreen = + BasicColor.of("green", COLOR_IDX_GREEN, Intensity.dark); + public static final BasicColor darkYellow = + BasicColor.of("yellow", COLOR_IDX_YELLOW, Intensity.dark); + public static final BasicColor darkBlue = + BasicColor.of("blue", COLOR_IDX_BLUE, Intensity.dark); + public static final BasicColor darkMagenta = + BasicColor.of("magenta", COLOR_IDX_MAGENTA, Intensity.dark); + public static final BasicColor darkCyan = + BasicColor.of("cyan", COLOR_IDX_CYAN, Intensity.dark); + public static final BasicColor darkWhite = + BasicColor.of("white", COLOR_IDX_WHITE, Intensity.dark); + + public static final BasicColor brightBlack = + BasicColor.of("black", COLOR_IDX_BLACK, Intensity.bright); + public static final BasicColor brightRed = + BasicColor.of("red", COLOR_IDX_RED, Intensity.bright); + public static final BasicColor brightGreen = + BasicColor.of("green", COLOR_IDX_GREEN, Intensity.bright); + public static final BasicColor brightYellow = + BasicColor.of("yellow", COLOR_IDX_YELLOW, Intensity.bright); + public static final BasicColor brightBlue = + BasicColor.of("blue", COLOR_IDX_BLUE, Intensity.bright); + public static final BasicColor brightMagenta = + BasicColor.of("magenta", COLOR_IDX_MAGENTA, Intensity.bright); + public static final BasicColor brightCyan = + BasicColor.of("cyan", COLOR_IDX_CYAN, Intensity.bright); + public static final BasicColor brightWhite = + BasicColor.of("white", COLOR_IDX_WHITE, Intensity.bright); + + public static final BasicColor[] normalColors = { + black, red, green, yellow, + blue, magenta, cyan, white + }; + + public static final BasicColor[] darkColors = { + darkBlack, darkRed, darkGreen, darkYellow, + darkBlue, darkMagenta, darkCyan, darkWhite + }; + + public static final BasicColor[] brightColors = { + brightBlack, brightRed, brightGreen, brightYellow, + brightBlue, brightMagenta, brightCyan, brightWhite + }; + + public static final int FOREGROUND_BASE = 30; + public static final int FOREGROUND_DARK_BASE = 60; + public static final int FOREGROUND_BRIGHT_BASE = 90; + public static final int BACKGROUND_BASE = 40; + public static final int BACKGROUND_DARK_BASE = 70; + public static final int BACKGROUND_BRIGHT_BASE = 100; + + protected static BasicColor of(String name, int index, Intensity intensity) { + return new BasicColor(name, index, intensity); + } + + public static BasicColor byIndex(int index) { + return normalColors[index]; + } + + public static BasicColor byIndex(int index, Intensity intensity) { + switch (intensity) { + case dark: + return darkColors[index]; + case bright: + return brightColors[index]; + default: + return normalColors[index]; + } + } + + private BasicColor(String name, int index, Intensity intensity) { + if (index < 0 || index > 7) { + throw new IllegalArgumentException( + "Color index must be between 0 and 7, got: " + index); + } + this.name = name; + this.index = index; + this.intensity = intensity; + this.fgStyle = foregroundStyle(index, intensity); + this.bgStyle = backgroundStyle(index, intensity); + } + + public String name() { + return name; + } + + public int index() { + return index; + } + + public Intensity intensity() { + return intensity; + } + + public BasicColor normal() { + if (intensity == Intensity.normal) { + return this; + } else { + return normalColors[index]; + } + } + + public BasicColor dark() { + if (intensity == Intensity.dark) { + return this; + } else { + return darkColors[index]; + } + } + + public BasicColor bright() { + if (intensity == Intensity.bright) { + return this; + } else { + return brightColors[index]; + } + } + + public String toForegroundStyle() { + return fgStyle; + } + + public String toBackgroundStyle() { + return bgStyle; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + BasicColor that = (BasicColor) o; + return index == that.index && intensity == that.intensity; + } + + @Override + public int hashCode() { + return Objects.hash(index, intensity); + } + + @Override + public String toString() { + switch (intensity) { + case normal: + return name; + case dark: + return "dark " + name; + case bright: + return "bright " + name; + default: + throw new IllegalArgumentException("Unknown mode: " + intensity); + } + } + + public static String foregroundStyle(int index, Intensity intensity) { + switch (intensity) { + case normal: + return foregroundStyle(index); + case dark: + return foregroundDarkStyle(index); + case bright: + return foregroundBrightStyle(index); + default: + throw new IllegalArgumentException("Unknown mode: " + intensity); + } + } + + public static String foregroundStyle(int index) { + return String.valueOf(FOREGROUND_BASE + index); + } + + public static String foregroundDarkStyle(int index) { + return String.valueOf(FOREGROUND_DARK_BASE + index); + } + + public static String foregroundBrightStyle(int index) { + return String.valueOf(FOREGROUND_BRIGHT_BASE + index); + } + + public static String backgroundStyle(int index, Intensity intensity) { + switch (intensity) { + case normal: + return backgroundStyle(index); + case dark: + return backgroundDarkStyle(index); + case bright: + return backgroundBrightStyle(index); + default: + throw new IllegalArgumentException("Unknown mode: " + intensity); + } + } + + public static String backgroundStyle(int index) { + return String.valueOf(BACKGROUND_BASE + index); + } + + public static String backgroundDarkStyle(int index) { + return String.valueOf(BACKGROUND_DARK_BASE + index); + } + + public static String backgroundBrightStyle(int index) { + return String.valueOf(BACKGROUND_BRIGHT_BASE + index); + } } - @Override - public int hashCode() { - int result = r; - result = 31 * result + g; - result = 31 * result + b; - return result; + class IndexedColor implements Color { + private final int index; + private final String fgStyle; + private final String bgStyle; + + public static IndexedColor of(int index) { + return new IndexedColor(index); + } + + protected IndexedColor(int index) { + if (index < 0 || index > 255) { + throw new IllegalArgumentException( + "Color index must be between 0 and 255, got: " + index); + } + this.index = index; + this.fgStyle = foregroundIndexedStyle(index); + this.bgStyle = backgroundIndexedStyle(index); + } + + public int index() { + return index; + } + + @Override + public String toForegroundStyle() { + return fgStyle; + } + + @Override + public String toBackgroundStyle() { + return bgStyle; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + IndexedColor that = (IndexedColor) o; + return index == that.index; + } + + @Override + public int hashCode() { + return Objects.hashCode(index); + } + + @Override + public String toString() { + return "%" + index; + } + + public static String foregroundIndexedStyle(int index) { + return STYLE_FOREGROUND + ";" + STYLE_INDEXED + ";" + index; + } + + public static String backgroundIndexedStyle(int index) { + return STYLE_BACKGROUND + ";" + STYLE_INDEXED + ";" + index; + } } - // ── Internal ────────────────────────────────────────────────────────── + class RgbColor implements Color { + private final int r, g, b; + private final String fgStyle; + private final String bgStyle; + private final String oscCode; - /** - * Scale a hex string of 1–4 digits to a 16-bit integer using digit-replication, matching the - * XTerm/X11 convention for the {@code rgb:} colour spec. - */ - private static int normalizeHex(String hex) { - if (hex.isEmpty() || hex.length() > 4) { - throw new NumberFormatException("invalid hex component length: '" + hex + "'"); - } - int val = Integer.parseInt(hex, 16); - switch (hex.length()) { - case 1: - return val * 0x1111; // nibble-repeat: F → FFFF - case 2: - return (val << 8) | val; // byte-repeat: FF → FFFF - case 3: - return (val << 4) | (val >> 8); // 12-bit left-justified: FFF → FFFF - case 4: - return val; - default: - return val; + /** + * Creates a colour from 16-bit per channel values (0–65535 each). + * + * @param r red channel 0–65535 + * @param g green channel 0–65535 + * @param b blue channel 0–65535 + * @return new colour + * @throws IllegalArgumentException if any component is out of range + */ + public static RgbColor of(int r, int g, int b) { + return new RgbColor(r, g, b); + } + + /** + * Creates a colour from 8-bit per channel values (0–255 each). + * + *

Each component is expanded to 16 bits by byte-replication ({@code 0xFF → 0xFFFF}). + * + * @param r red channel 0–255 + * @param g green channel 0–255 + * @param b blue channel 0–255 + * @return new colour + * @throws IllegalArgumentException if any component is out of range + */ + public static RgbColor ofRgb8(int r, int g, int b) { + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { + throw new IllegalArgumentException( + "8-bit RGB component out of range [0..255]: " + r + "," + g + "," + b); + } + return new RgbColor((r << 8) | r, (g << 8) | g, (b << 8) | b); + } + + protected RgbColor(int r, int g, int b) { + if (r < 0 || r > 65535 || g < 0 || g > 65535 || b < 0 || b > 65535) { + throw new IllegalArgumentException( + "RGB component out of range [0..65535]: " + r + "," + g + "," + b); + } + this.r = r; + this.g = g; + this.b = b; + this.fgStyle = foregroundRgbStyle(r8(), g8(), b8()); + this.bgStyle = backgroundRgbStyle(r8(), g8(), b8()); + this.oscCode = oscCode(r, g, b); + } + + /** Red channel as a 16-bit value (0–65535). */ + public int r() { + return r; + } + + /** Green channel as a 16-bit value (0–65535). */ + public int g() { + return g; + } + + /** Blue channel as a 16-bit value (0–65535). */ + public int b() { + return b; + } + + /** Red channel downsampled to 8 bits (0–255). */ + public int r8() { + return r >> 8; + } + + /** Green channel downsampled to 8 bits (0–255). */ + public int g8() { + return g >> 8; + } + + /** Blue channel downsampled to 8 bits (0–255). */ + public int b8() { + return b >> 8; + } + + /** + * Parses an X11 {@code rgb:} colour specification. + * + *

Accepts 1–4 hex digits per channel. Each component is scaled to 16 bits: + * + *

+ * + * @param s string starting with {@code rgb:} followed by three hex components separated by + * {@code /} + * @return parsed colour, or {@code null} if {@code s} is null, does not start with {@code + * rgb:}, or contains invalid hex digits + */ + public static RgbColor parse(String s) { + if (s == null) return null; + String t = s.trim(); + if (!t.startsWith("rgb:")) return null; + String[] parts = t.substring(4).split("/", -1); + if (parts.length != 3) return null; + try { + int r = normalizeHex(parts[0].trim()); + int g = normalizeHex(parts[1].trim()); + int b = normalizeHex(parts[2].trim()); + return new RgbColor(r, g, b); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Returns the colour as a packed 24-bit RGB integer ({@code 0xRRGGBB}), using the 8-bit + * downsampled values. + * + * @return packed RGB integer + */ + public int toRgb8() { + return (r8() << 16) | (g8() << 8) | b8(); + } + + @Override + public String toForegroundStyle() { + return fgStyle; + } + + @Override + public String toBackgroundStyle() { + return bgStyle; + } + + public String toOscCode() { + return oscCode; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + RgbColor rgbColor = (RgbColor) o; + return r == rgbColor.r && g == rgbColor.g && b == rgbColor.b; + } + + @Override + public int hashCode() { + return Objects.hash(r, g, b); + } + + @Override + public String toString() { + return String.format("#%02x%02x%02x", r, g, b); + } + + public static String foregroundRgbStyle(int r, int g, int b) { + return STYLE_FOREGROUND + ";" + rgbStyle(r, g, b); + } + + public static String backgroundRgbStyle(int r, int g, int b) { + return STYLE_BACKGROUND + ";" + rgbStyle(r, g, b); + } + + public static String rgbStyle(int r, int g, int b) { + return STYLE_RGB + ";" + r + ";" + g + ";" + b; + } + + public static String oscCode(int r, int g, int b) { + return String.format("rgb:%04X/%04X/%04X", r, g, b); + } + + /** + * Scale a hex string of 1–4 digits to a 16-bit integer using digit-replication, matching + * the XTerm/X11 convention for the {@code rgb:} colour spec. + */ + private static int normalizeHex(String hex) { + if (hex.isEmpty() || hex.length() > 4) { + throw new NumberFormatException("invalid hex component length: '" + hex + "'"); + } + int val = Integer.parseInt(hex, 16); + switch (hex.length()) { + case 1: + return val * 0x1111; // nibble-repeat: F → FFFF + case 2: + return (val << 8) | val; // byte-repeat: FF → FFFF + case 3: + return (val << 4) | (val >> 8); // 12-bit left-justified: FFF → FFFF + case 4: + return val; + default: + return val; + } } } } diff --git a/colors/src/main/java/org/codejive/miniterm/colors/TermColors.java b/colors/src/main/java/org/codejive/miniterm/colors/TermColors.java index 7a1ed37..a584af6 100644 --- a/colors/src/main/java/org/codejive/miniterm/colors/TermColors.java +++ b/colors/src/main/java/org/codejive/miniterm/colors/TermColors.java @@ -8,6 +8,7 @@ import java.util.function.Function; import org.codejive.miniterm.ansiparser.AnsiParser; import org.codejive.miniterm.ansiparser.IntReader; +import org.codejive.miniterm.colors.Color.RgbColor; /** * Static helpers for querying and setting terminal colours via OSC escape sequences. @@ -19,7 +20,7 @@ * *
{@code
  * terminal.enableRawMode();
- * Color bg = TermColors.queryBackground(terminal, () -> terminal.read(500));
+ * RgbColor bg = TermColors.queryBackground(terminal, () -> terminal.read(500));
  * }
* *

Set and reset methods perform only output and can be called at any time. @@ -47,8 +48,8 @@ * *

* *

Reset methods

@@ -65,14 +66,14 @@ *

Utility methods

* * * *

Burst palette query

* *

{@link #queryPalette} sends all requested queries at once, then collects responses until a * read timeout indicates the terminal has no more data to send. Results are returned as a {@code - * Color[256]} array indexed by palette entry number; entries for which no response arrived are + * RgbColor[256]} array indexed by palette entry number; entries for which no response arrived are * {@code null}. * *

SSH sessions

@@ -159,7 +160,7 @@ public static void sendColorQuery(Appendable out, int index) throws IOException * @return the current foreground colour, or {@code null} if the terminal did not respond * @throws IOException if writing fails */ - public static Color queryForeground(Appendable out, IntReader in) throws IOException { + public static RgbColor queryForeground(Appendable out, IntReader in) throws IOException { sendForegroundQuery(out); return readSingleColor(in, TermColors::parseForeground); } @@ -172,7 +173,7 @@ public static Color queryForeground(Appendable out, IntReader in) throws IOExcep * @return the current background colour, or {@code null} if the terminal did not respond * @throws IOException if writing fails */ - public static Color queryBackground(Appendable out, IntReader in) throws IOException { + public static RgbColor queryBackground(Appendable out, IntReader in) throws IOException { sendBackgroundQuery(out); return readSingleColor(in, TermColors::parseBackground); } @@ -185,7 +186,7 @@ public static Color queryBackground(Appendable out, IntReader in) throws IOExcep * @return the current cursor colour, or {@code null} if the terminal did not respond * @throws IOException if writing fails */ - public static Color queryCursor(Appendable out, IntReader in) throws IOException { + public static RgbColor queryCursor(Appendable out, IntReader in) throws IOException { sendCursorQuery(out); return readSingleColor(in, TermColors::parseCursor); } @@ -200,7 +201,7 @@ public static Color queryCursor(Appendable out, IntReader in) throws IOException * @throws IllegalArgumentException if {@code index} is out of range * @throws IOException if writing fails */ - public static Color queryColor(Appendable out, IntReader in, int index) throws IOException { + public static RgbColor queryColor(Appendable out, IntReader in, int index) throws IOException { sendColorQuery(out, index); return readPaletteColor(in, index); } @@ -218,7 +219,7 @@ public static Color queryColor(Appendable out, IntReader in, int index) throws I * respond for that index * @throws IOException if writing fails */ - public static Color[] queryPalette(Appendable out, IntReader in) throws IOException { + public static RgbColor[] queryPalette(Appendable out, IntReader in) throws IOException { int[] all = new int[256]; for (int i = 0; i < 256; i++) all[i] = i; return queryPalette(out, in, all); @@ -238,7 +239,7 @@ public static Color[] queryPalette(Appendable out, IntReader in) throws IOExcept * @throws IllegalArgumentException if any index is out of range * @throws IOException if writing fails */ - public static Color[] queryPalette(Appendable out, IntReader in, int[] indices) + public static RgbColor[] queryPalette(Appendable out, IntReader in, int[] indices) throws IOException { boolean[] requested = new boolean[256]; int requestedCount = 0; @@ -256,7 +257,7 @@ public static Color[] queryPalette(Appendable out, IntReader in, int[] indices) } // Collect responses until timeout - Color[] result = new Color[256]; + RgbColor[] result = new RgbColor[256]; int received = 0; int maxAttempts = requestedCount * 2 + 32; for (int attempt = 0; attempt < maxAttempts; attempt++) { @@ -281,7 +282,7 @@ public static Color[] queryPalette(Appendable out, IntReader in, int[] indices) } if (colorIndex < 0 || colorIndex > 255) continue; - Color color = parseColor(seq, colorIndex); + RgbColor color = parseColor(seq, colorIndex); if (color != null && result[colorIndex] == null && requested[colorIndex]) { result[colorIndex] = color; received++; @@ -300,8 +301,8 @@ public static Color[] queryPalette(Appendable out, IntReader in, int[] indices) * @param color new foreground colour * @throws IOException if writing fails */ - public static void setForeground(Appendable out, Color color) throws IOException { - out.append(OSC).append("10;").append(color.toString()).append(OSC_BEL); + public static void setForeground(Appendable out, RgbColor color) throws IOException { + out.append(OSC).append("10;").append(color.toOscCode()).append(OSC_BEL); } /** @@ -311,8 +312,8 @@ public static void setForeground(Appendable out, Color color) throws IOException * @param color new background colour * @throws IOException if writing fails */ - public static void setBackground(Appendable out, Color color) throws IOException { - out.append(OSC).append("11;").append(color.toString()).append(OSC_BEL); + public static void setBackground(Appendable out, RgbColor color) throws IOException { + out.append(OSC).append("11;").append(color.toOscCode()).append(OSC_BEL); } /** @@ -322,8 +323,8 @@ public static void setBackground(Appendable out, Color color) throws IOException * @param color new cursor colour * @throws IOException if writing fails */ - public static void setCursor(Appendable out, Color color) throws IOException { - out.append(OSC).append("12;").append(color.toString()).append(OSC_BEL); + public static void setCursor(Appendable out, RgbColor color) throws IOException { + out.append(OSC).append("12;").append(color.toOscCode()).append(OSC_BEL); } /** @@ -335,13 +336,13 @@ public static void setCursor(Appendable out, Color color) throws IOException { * @throws IllegalArgumentException if {@code index} is out of range * @throws IOException if writing fails */ - public static void setColor(Appendable out, int index, Color color) throws IOException { + public static void setColor(Appendable out, int index, RgbColor color) throws IOException { validateIndex(index); out.append(OSC) .append("4;") .append(String.valueOf(index)) .append(";") - .append(color.toString()) + .append(color.toOscCode()) .append(OSC_BEL); } @@ -355,7 +356,7 @@ public static void setColor(Appendable out, int index, Color color) throws IOExc * @throws IllegalArgumentException if {@code colors} does not have exactly 256 elements * @throws IOException if writing fails */ - public static void setPalette(Appendable out, Color[] colors) throws IOException { + public static void setPalette(Appendable out, RgbColor[] colors) throws IOException { if (colors.length != 256) { throw new IllegalArgumentException( "palette array must have exactly 256 entries, got " + colors.length); @@ -434,11 +435,11 @@ public static void resetPalette(Appendable out) throws IOException { * @return index of the nearest non-{@code null} entry in {@code palette}, or {@code -1} if * {@code palette} is empty or contains only {@code null} entries */ - public static int nearest(Color target, Color[] palette) { + public static int nearest(RgbColor target, RgbColor[] palette) { int bestIndex = -1; long bestDistance = Long.MAX_VALUE; for (int i = 0; i < palette.length; i++) { - Color c = palette[i]; + RgbColor c = palette[i]; if (c == null) continue; long distance = distance(target, c); if (distance < bestDistance) { @@ -454,7 +455,7 @@ public static int nearest(Color target, Color[] palette) { * downsampled components. Cheaper than a full CIE Lab conversion while still weighting channels * closer to human colour perception than plain Euclidean RGB distance. */ - private static long distance(Color a, Color b) { + private static long distance(RgbColor a, RgbColor b) { long meanR = (a.r8() + b.r8()) / 2; long dr = a.r8() - b.r8(); long dg = a.g8() - b.g8(); @@ -518,9 +519,9 @@ public static boolean isColorQueryResult(String seq, int index) { * @param seq raw escape sequence * @return the parsed colour, or {@code null} if {@code seq} is not a valid OSC 10 response */ - public static Color parseForeground(String seq) { + public static RgbColor parseForeground(String seq) { if (!isForegroundQueryResult(seq)) return null; - return Color.parse(oscBody(seq).substring(3)); + return RgbColor.parse(oscBody(seq).substring(3)); } /** @@ -529,9 +530,9 @@ public static Color parseForeground(String seq) { * @param seq raw escape sequence * @return the parsed colour, or {@code null} if {@code seq} is not a valid OSC 11 response */ - public static Color parseBackground(String seq) { + public static RgbColor parseBackground(String seq) { if (!isBackgroundQueryResult(seq)) return null; - return Color.parse(oscBody(seq).substring(3)); + return RgbColor.parse(oscBody(seq).substring(3)); } /** @@ -540,9 +541,9 @@ public static Color parseBackground(String seq) { * @param seq raw escape sequence * @return the parsed colour, or {@code null} if {@code seq} is not a valid OSC 12 response */ - public static Color parseCursor(String seq) { + public static RgbColor parseCursor(String seq) { if (!isCursorQueryResult(seq)) return null; - return Color.parse(oscBody(seq).substring(3)); + return RgbColor.parse(oscBody(seq).substring(3)); } /** @@ -553,33 +554,33 @@ public static Color parseCursor(String seq) { * @return the parsed colour, or {@code null} if {@code seq} is not a valid OSC 4 response for * {@code expectedIndex} */ - public static Color parseColor(String seq, int expectedIndex) { + public static RgbColor parseColor(String seq, int expectedIndex) { if (!isColorQueryResult(seq, expectedIndex)) return null; String prefix = "4;" + expectedIndex + ";"; - return Color.parse(oscBody(seq).substring(prefix.length())); + return RgbColor.parse(oscBody(seq).substring(prefix.length())); } // ── Internal helpers ────────────────────────────────────────────────── /** - * Reads ANSI sequences from {@code in} until {@code parser} returns a non-null {@link Color}, - * or until a timeout or EOF occurs. + * Reads ANSI sequences from {@code in} until {@code parser} returns a non-null {@link + * RgbColor}, or until a timeout or EOF occurs. */ - private static Color readSingleColor(IntReader in, Function parser) + private static RgbColor readSingleColor(IntReader in, Function parser) throws IOException { for (int attempt = 0; attempt < 32; attempt++) { StringBuilder sb = new StringBuilder(); AnsiParser.parse(sb, in, AnsiParser.MAX_SEQUENCE_LENGTH); String seq = sb.toString(); if (seq.isEmpty()) return null; // timeout - Color c = parser.apply(seq); + RgbColor c = parser.apply(seq); if (c != null) return c; } return null; } /** Like {@link #readSingleColor} but matches an OSC 4 response for a specific palette index. */ - private static Color readPaletteColor(IntReader in, int expectedIndex) throws IOException { + private static RgbColor readPaletteColor(IntReader in, int expectedIndex) throws IOException { return readSingleColor(in, seq -> parseColor(seq, expectedIndex)); } diff --git a/colors/src/test/java/org/codejive/miniterm/colors/ColorTest.java b/colors/src/test/java/org/codejive/miniterm/colors/ColorTest.java index 8fb7b45..e959470 100644 --- a/colors/src/test/java/org/codejive/miniterm/colors/ColorTest.java +++ b/colors/src/test/java/org/codejive/miniterm/colors/ColorTest.java @@ -11,7 +11,7 @@ class ColorTest { @Test void of_storesComponents() { - Color c = Color.of(0x1234, 0x5678, 0x9ABC); + Color.RgbColor c = Color.rgb(0x1234, 0x5678, 0x9ABC); assertThat(c.r()).isEqualTo(0x1234); assertThat(c.g()).isEqualTo(0x5678); assertThat(c.b()).isEqualTo(0x9ABC); @@ -19,25 +19,25 @@ void of_storesComponents() { @Test void of_acceptsBoundaryValues() { - Color black = Color.of(0, 0, 0); + Color.RgbColor black = Color.rgb(0, 0, 0); assertThat(black.r()).isZero(); - Color white = Color.of(65535, 65535, 65535); + Color.RgbColor white = Color.rgb(65535, 65535, 65535); assertThat(white.r()).isEqualTo(65535); } @Test - void of_rejectsOutOfRange() { - assertThatThrownBy(() -> Color.of(-1, 0, 0)).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Color.of(0, 65536, 0)) + void rgb_rejectsOutOfRange() { + assertThatThrownBy(() -> Color.rgb(-1, 0, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Color.rgb(0, 65536, 0)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Color.of(0, 0, 100000)) + assertThatThrownBy(() -> Color.rgb(0, 0, 100000)) .isInstanceOf(IllegalArgumentException.class); } @Test - void ofRgb8_expandsToByte16() { - Color c = Color.ofRgb8(0xFF, 0x80, 0x00); + void rgb8_expandsToByte16() { + Color.RgbColor c = Color.rgb8(0xFF, 0x80, 0x00); // byte-replication: 0xFF -> 0xFFFF, 0x80 -> 0x8080, 0x00 -> 0x0000 assertThat(c.r()).isEqualTo(0xFFFF); assertThat(c.g()).isEqualTo(0x8080); @@ -45,10 +45,9 @@ void ofRgb8_expandsToByte16() { } @Test - void ofRgb8_rejectsOutOfRange() { - assertThatThrownBy(() -> Color.ofRgb8(-1, 0, 0)) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Color.ofRgb8(0, 256, 0)) + void rgb8_rejectsOutOfRange() { + assertThatThrownBy(() -> Color.rgb8(-1, 0, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Color.rgb8(0, 256, 0)) .isInstanceOf(IllegalArgumentException.class); } @@ -56,7 +55,7 @@ void ofRgb8_rejectsOutOfRange() { @Test void r8_g8_b8_downsampleCorrectly() { - Color c = Color.of(0xABCD, 0x1234, 0x0000); + Color.RgbColor c = Color.rgb(0xABCD, 0x1234, 0x0000); assertThat(c.r8()).isEqualTo(0xAB); assertThat(c.g8()).isEqualTo(0x12); assertThat(c.b8()).isZero(); @@ -64,14 +63,14 @@ void r8_g8_b8_downsampleCorrectly() { @Test void toRgb8_packsCorrectly() { - Color c = Color.of(0xFF00, 0x8000, 0x0000); + Color.RgbColor c = Color.rgb(0xFF00, 0x8000, 0x0000); // r8=0xFF, g8=0x80, b8=0x00 → 0xFF8000 assertThat(c.toRgb8()).isEqualTo(0xFF8000); } @Test - void ofRgb8_roundTrip_via8bitGetters() { - Color c = Color.ofRgb8(200, 100, 50); + void rgb8_roundTrip_via8bitGetters() { + Color.RgbColor c = Color.rgb8(200, 100, 50); assertThat(c.r8()).isEqualTo(200); assertThat(c.g8()).isEqualTo(100); assertThat(c.b8()).isEqualTo(50); @@ -81,15 +80,15 @@ void ofRgb8_roundTrip_via8bitGetters() { @Test void parse_4digit_roundTrip() { - Color original = Color.of(0xABCD, 0x1234, 0xEF01); - Color parsed = Color.parse(original.toString()); + Color.RgbColor original = Color.rgb(0xABCD, 0x1234, 0xEF01); + Color.RgbColor parsed = Color.RgbColor.parse(original.toOscCode()); assertThat(parsed).isEqualTo(original); } @Test void parse_2digit_byteReplicated() { // rgb:FF/00/80 → r=0xFFFF, g=0x0000, b=0x8080 - Color c = Color.parse("rgb:FF/00/80"); + Color.RgbColor c = Color.RgbColor.parse("rgb:FF/00/80"); assertThat(c).isNotNull(); assertThat(c.r()).isEqualTo(0xFFFF); assertThat(c.g()).isEqualTo(0x0000); @@ -99,7 +98,7 @@ void parse_2digit_byteReplicated() { @Test void parse_1digit_nibbleReplicated() { // rgb:F/0/8 → r=0xFFFF, g=0x0000, b=0x8888 - Color c = Color.parse("rgb:F/0/8"); + Color.RgbColor c = Color.RgbColor.parse("rgb:F/0/8"); assertThat(c).isNotNull(); assertThat(c.r()).isEqualTo(0xFFFF); assertThat(c.g()).isEqualTo(0x0000); @@ -108,74 +107,74 @@ void parse_1digit_nibbleReplicated() { @Test void parse_caseInsensitive() { - Color lower = Color.parse("rgb:ffff/0000/abcd"); - Color upper = Color.parse("rgb:FFFF/0000/ABCD"); + Color.RgbColor lower = Color.RgbColor.parse("rgb:ffff/0000/abcd"); + Color.RgbColor upper = Color.RgbColor.parse("rgb:FFFF/0000/ABCD"); assertThat(lower).isEqualTo(upper); } @Test void parse_leadingWhitespace_trimmed() { - Color c = Color.parse(" rgb:FFFF/0000/0000 "); + Color.RgbColor c = Color.RgbColor.parse(" rgb:FFFF/0000/0000 "); assertThat(c).isNotNull(); assertThat(c.r()).isEqualTo(0xFFFF); } @Test void parse_null_returnsNull() { - assertThat(Color.parse(null)).isNull(); + assertThat(Color.RgbColor.parse(null)).isNull(); } @Test void parse_missingPrefix_returnsNull() { - assertThat(Color.parse("FFFF/0000/0000")).isNull(); - assertThat(Color.parse("#FFFFFF")).isNull(); + assertThat(Color.RgbColor.parse("FFFF/0000/0000")).isNull(); + assertThat(Color.RgbColor.parse("#FFFFFF")).isNull(); } @Test void parse_wrongComponentCount_returnsNull() { - assertThat(Color.parse("rgb:FFFF/0000")).isNull(); - assertThat(Color.parse("rgb:FFFF/0000/0000/FFFF")).isNull(); + assertThat(Color.RgbColor.parse("rgb:FFFF/0000")).isNull(); + assertThat(Color.RgbColor.parse("rgb:FFFF/0000/0000/FFFF")).isNull(); } @Test void parse_invalidHex_returnsNull() { - assertThat(Color.parse("rgb:GGGG/0000/0000")).isNull(); - assertThat(Color.parse("rgb:FFFFF/0000/0000")).isNull(); + assertThat(Color.RgbColor.parse("rgb:GGGG/0000/0000")).isNull(); + assertThat(Color.RgbColor.parse("rgb:FFFFF/0000/0000")).isNull(); } // ── toString ────────────────────────────────────────────────────────── @Test void toString_producesCanonicalForm() { - Color c = Color.of(0xABCD, 0x0123, 0xFFFF); - assertThat(c.toString()).isEqualTo("rgb:ABCD/0123/FFFF"); + Color.RgbColor c = Color.rgb(0xABCD, 0x0123, 0xFFFF); + assertThat(c.toOscCode()).isEqualTo("rgb:ABCD/0123/FFFF"); } @Test void toString_padsToFourDigits() { - Color c = Color.of(0x000F, 0x0000, 0x0001); - assertThat(c.toString()).isEqualTo("rgb:000F/0000/0001"); + Color.RgbColor c = Color.rgb(0x000F, 0x0000, 0x0001); + assertThat(c.toOscCode()).isEqualTo("rgb:000F/0000/0001"); } // ── Equality ───────────────────────────────────────────────────────── @Test void equals_sameValues_areEqual() { - Color a = Color.of(0x1234, 0x5678, 0x9ABC); - Color b = Color.of(0x1234, 0x5678, 0x9ABC); + Color.RgbColor a = Color.rgb(0x1234, 0x5678, 0x9ABC); + Color.RgbColor b = Color.rgb(0x1234, 0x5678, 0x9ABC); assertThat(a).isEqualTo(b); assertThat(a.hashCode()).isEqualTo(b.hashCode()); } @Test void equals_differentValues_areNotEqual() { - Color a = Color.of(0x1234, 0x5678, 0x9ABC); - Color b = Color.of(0x1234, 0x5678, 0x0000); + Color.RgbColor a = Color.rgb(0x1234, 0x5678, 0x9ABC); + Color.RgbColor b = Color.rgb(0x1234, 0x5678, 0x0000); assertThat(a).isNotEqualTo(b); } @Test void equals_null_returnsFalse() { - assertThat(Color.of(0, 0, 0)).isNotEqualTo(null); + assertThat(Color.rgb(0, 0, 0)).isNotEqualTo(null); } } diff --git a/colors/src/test/java/org/codejive/miniterm/colors/TermColorsTest.java b/colors/src/test/java/org/codejive/miniterm/colors/TermColorsTest.java index 465e972..8912320 100644 --- a/colors/src/test/java/org/codejive/miniterm/colors/TermColorsTest.java +++ b/colors/src/test/java/org/codejive/miniterm/colors/TermColorsTest.java @@ -5,6 +5,7 @@ import java.io.IOException; import org.codejive.miniterm.ansiparser.IntReader; +import org.codejive.miniterm.colors.Color.RgbColor; import org.junit.jupiter.api.Test; class TermColorsTest { @@ -120,7 +121,7 @@ void isColorQueryResult_withIndex() { @Test void parseForeground_validSequence() { String seq = "\033]10;rgb:FFFF/0000/0000\007"; - assertThat(TermColors.parseForeground(seq)).isEqualTo(Color.of(0xFFFF, 0x0000, 0x0000)); + assertThat(TermColors.parseForeground(seq)).isEqualTo(Color.rgb(0xFFFF, 0x0000, 0x0000)); } @Test @@ -132,7 +133,7 @@ void parseForeground_wrongOscCode_returnsNull() { @Test void parseBackground_validSequence() { String seq = "\033]11;rgb:1234/5678/9ABC\007"; - assertThat(TermColors.parseBackground(seq)).isEqualTo(Color.of(0x1234, 0x5678, 0x9ABC)); + assertThat(TermColors.parseBackground(seq)).isEqualTo(Color.rgb(0x1234, 0x5678, 0x9ABC)); } @Test @@ -143,19 +144,19 @@ void parseBackground_wrongOscCode_returnsNull() { @Test void parseCursor_validSequence() { String seq = "\033]12;rgb:0000/FFFF/0000\007"; - assertThat(TermColors.parseCursor(seq)).isEqualTo(Color.of(0x0000, 0xFFFF, 0x0000)); + assertThat(TermColors.parseCursor(seq)).isEqualTo(Color.rgb(0x0000, 0xFFFF, 0x0000)); } @Test void parseCursor_stTerminated() { String seq = "\033]12;rgb:0000/FFFF/0000\033\\"; - assertThat(TermColors.parseCursor(seq)).isEqualTo(Color.of(0x0000, 0xFFFF, 0x0000)); + assertThat(TermColors.parseCursor(seq)).isEqualTo(Color.rgb(0x0000, 0xFFFF, 0x0000)); } @Test void parseColor_validSequence() { String seq = "\033]4;7;rgb:AAAA/BBBB/CCCC\007"; - assertThat(TermColors.parseColor(seq, 7)).isEqualTo(Color.of(0xAAAA, 0xBBBB, 0xCCCC)); + assertThat(TermColors.parseColor(seq, 7)).isEqualTo(Color.rgb(0xAAAA, 0xBBBB, 0xCCCC)); } @Test @@ -174,44 +175,44 @@ void parseColor_wrongOscCode_returnsNull() { @Test void setForeground_writesCorrectSequence() throws IOException { StringBuilder out = new StringBuilder(); - TermColors.setForeground(out, Color.of(0xFFFF, 0x0000, 0x0000)); + TermColors.setForeground(out, Color.rgb(0xFFFF, 0x0000, 0x0000)); assertThat(out.toString()).isEqualTo("\033]10;rgb:FFFF/0000/0000\007"); } @Test void setBackground_writesCorrectSequence() throws IOException { StringBuilder out = new StringBuilder(); - TermColors.setBackground(out, Color.of(0x0000, 0x0000, 0x0000)); + TermColors.setBackground(out, Color.rgb(0x0000, 0x0000, 0x0000)); assertThat(out.toString()).isEqualTo("\033]11;rgb:0000/0000/0000\007"); } @Test void setCursor_writesCorrectSequence() throws IOException { StringBuilder out = new StringBuilder(); - TermColors.setCursor(out, Color.of(0xFFFF, 0xFFFF, 0x0000)); + TermColors.setCursor(out, Color.rgb(0xFFFF, 0xFFFF, 0x0000)); assertThat(out.toString()).isEqualTo("\033]12;rgb:FFFF/FFFF/0000\007"); } @Test void setColor_writesCorrectSequence() throws IOException { StringBuilder out = new StringBuilder(); - TermColors.setColor(out, 7, Color.of(0xAAAA, 0xBBBB, 0xCCCC)); + TermColors.setColor(out, 7, Color.rgb(0xAAAA, 0xBBBB, 0xCCCC)); assertThat(out.toString()).isEqualTo("\033]4;7;rgb:AAAA/BBBB/CCCC\007"); } @Test void setColor_rejectsOutOfRange() { - assertThatThrownBy(() -> TermColors.setColor(new StringBuilder(), -1, Color.of(0, 0, 0))) + assertThatThrownBy(() -> TermColors.setColor(new StringBuilder(), -1, Color.rgb(0, 0, 0))) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> TermColors.setColor(new StringBuilder(), 256, Color.of(0, 0, 0))) + assertThatThrownBy(() -> TermColors.setColor(new StringBuilder(), 256, Color.rgb(0, 0, 0))) .isInstanceOf(IllegalArgumentException.class); } @Test void setPalette_writesAllNonNullEntries() throws IOException { - Color[] palette = new Color[256]; - palette[0] = Color.of(0x0000, 0x0000, 0x0000); - palette[255] = Color.of(0xFFFF, 0xFFFF, 0xFFFF); + RgbColor[] palette = new RgbColor[256]; + palette[0] = Color.rgb(0x0000, 0x0000, 0x0000); + palette[255] = Color.rgb(0xFFFF, 0xFFFF, 0xFFFF); StringBuilder out = new StringBuilder(); TermColors.setPalette(out, palette); @@ -225,7 +226,7 @@ void setPalette_writesAllNonNullEntries() throws IOException { @Test void setPalette_rejectsWrongLength() { - assertThatThrownBy(() -> TermColors.setPalette(new StringBuilder(), new Color[100])) + assertThatThrownBy(() -> TermColors.setPalette(new StringBuilder(), new RgbColor[100])) .isInstanceOf(IllegalArgumentException.class); } @@ -270,35 +271,35 @@ void resetPalette_writesCorrectSequence() throws IOException { @Test void nearest_findsExactMatch() { - Color[] palette = { - Color.ofRgb8(0, 0, 0), Color.ofRgb8(255, 0, 0), Color.ofRgb8(0, 255, 0), + RgbColor[] palette = { + Color.rgb8(0, 0, 0), Color.rgb8(255, 0, 0), Color.rgb8(0, 255, 0), }; - assertThat(TermColors.nearest(Color.ofRgb8(255, 0, 0), palette)).isEqualTo(1); + assertThat(TermColors.nearest(Color.rgb8(255, 0, 0), palette)).isEqualTo(1); } @Test void nearest_findsClosestMatch() { - Color[] palette = { - Color.ofRgb8(0, 0, 0), Color.ofRgb8(200, 0, 0), Color.ofRgb8(255, 255, 255), + Color.RgbColor[] palette = { + Color.rgb8(0, 0, 0), Color.rgb8(200, 0, 0), Color.rgb8(255, 255, 255), }; - assertThat(TermColors.nearest(Color.ofRgb8(210, 10, 10), palette)).isEqualTo(1); + assertThat(TermColors.nearest(Color.rgb8(210, 10, 10), palette)).isEqualTo(1); } @Test void nearest_skipsNullEntries() { - Color[] palette = {Color.ofRgb8(255, 0, 0), null, Color.ofRgb8(0, 0, 0)}; - assertThat(TermColors.nearest(Color.ofRgb8(10, 10, 10), palette)).isEqualTo(2); + Color.RgbColor[] palette = {Color.rgb8(255, 0, 0), null, Color.rgb8(0, 0, 0)}; + assertThat(TermColors.nearest(Color.rgb8(10, 10, 10), palette)).isEqualTo(2); } @Test void nearest_emptyPalette_returnsMinusOne() { - assertThat(TermColors.nearest(Color.ofRgb8(0, 0, 0), new Color[0])).isEqualTo(-1); + assertThat(TermColors.nearest(Color.rgb8(0, 0, 0), new Color.RgbColor[0])).isEqualTo(-1); } @Test void nearest_allNullPalette_returnsMinusOne() { - Color[] palette = {null, null, null}; - assertThat(TermColors.nearest(Color.ofRgb8(0, 0, 0), palette)).isEqualTo(-1); + Color.RgbColor[] palette = {null, null, null}; + assertThat(TermColors.nearest(Color.rgb8(0, 0, 0), palette)).isEqualTo(-1); } // ── Query methods ───────────────────────────────────────────────────── @@ -311,21 +312,21 @@ void queryForeground_parsesResponse() throws IOException { Color result = TermColors.queryForeground(out, stringReader(response)); assertThat(out.toString()).isEqualTo("\033]10;?\007"); - assertThat(result).isEqualTo(Color.of(0xFFFF, 0x0000, 0x0000)); + assertThat(result).isEqualTo(Color.rgb(0xFFFF, 0x0000, 0x0000)); } @Test void queryBackground_parsesResponse() throws IOException { String response = "\033]11;rgb:1234/5678/9ABC\007"; Color result = TermColors.queryBackground(new StringBuilder(), stringReader(response)); - assertThat(result).isEqualTo(Color.of(0x1234, 0x5678, 0x9ABC)); + assertThat(result).isEqualTo(Color.rgb(0x1234, 0x5678, 0x9ABC)); } @Test void queryCursor_parsesResponse() throws IOException { String response = "\033]12;rgb:0000/FFFF/0000\007"; Color result = TermColors.queryCursor(new StringBuilder(), stringReader(response)); - assertThat(result).isEqualTo(Color.of(0x0000, 0xFFFF, 0x0000)); + assertThat(result).isEqualTo(Color.rgb(0x0000, 0xFFFF, 0x0000)); } @Test @@ -343,7 +344,7 @@ void queryColor_parsesSpecificIndex() throws IOException { Color result = TermColors.queryColor(out, stringReader(response), 7); assertThat(out.toString()).isEqualTo("\033]4;7;?\007"); - assertThat(result).isEqualTo(Color.of(0xAAAA, 0xBBBB, 0xCCCC)); + assertThat(result).isEqualTo(Color.rgb(0xAAAA, 0xBBBB, 0xCCCC)); } @Test @@ -351,7 +352,7 @@ void queryColor_skipsUnrelatedSequences() throws IOException { // First response has wrong index, second has the right one String responses = "\033]4;99;rgb:0000/0000/0000\007" + "\033]4;7;rgb:AAAA/BBBB/CCCC\007"; Color result = TermColors.queryColor(new StringBuilder(), stringReader(responses), 7); - assertThat(result).isEqualTo(Color.of(0xAAAA, 0xBBBB, 0xCCCC)); + assertThat(result).isEqualTo(Color.rgb(0xAAAA, 0xBBBB, 0xCCCC)); } @Test @@ -367,8 +368,8 @@ void queryPalette_collectsBurstResponses() throws IOException { // Verify queries were sent assertThat(out.toString()).contains("\033]4;0;?\007").contains("\033]4;3;?\007"); - assertThat(result[0]).isEqualTo(Color.of(0x0000, 0x0000, 0x0000)); - assertThat(result[3]).isEqualTo(Color.of(0xFFFF, 0x0000, 0x0000)); + assertThat(result[0]).isEqualTo(Color.rgb(0x0000, 0x0000, 0x0000)); + assertThat(result[3]).isEqualTo(Color.rgb(0xFFFF, 0x0000, 0x0000)); // Unrequested entries stay null assertThat(result[1]).isNull(); assertThat(result[2]).isNull(); @@ -380,7 +381,7 @@ void queryPalette_stTerminatedResponse() throws IOException { String response = "\033]4;1;rgb:1234/5678/ABCD\033\\"; Color[] result = TermColors.queryPalette(new StringBuilder(), stringReader(response), new int[] {1}); - assertThat(result[1]).isEqualTo(Color.of(0x1234, 0x5678, 0xABCD)); + assertThat(result[1]).isEqualTo(Color.rgb(0x1234, 0x5678, 0xABCD)); } @Test @@ -389,7 +390,7 @@ void queryPalette_stopsOnTimeout() throws IOException { String resp = "\033]4;5;rgb:ABCD/EF01/2345\007"; Color[] result = TermColors.queryPalette(new StringBuilder(), stringReader(resp), new int[] {5, 10}); - assertThat(result[5]).isEqualTo(Color.of(0xABCD, 0xEF01, 0x2345)); + assertThat(result[5]).isEqualTo(Color.rgb(0xABCD, 0xEF01, 0x2345)); assertThat(result[10]).isNull(); // timed out before receiving } diff --git a/examples/PrintColors.java b/examples/PrintColors.java index f130d64..83047bc 100755 --- a/examples/PrintColors.java +++ b/examples/PrintColors.java @@ -19,14 +19,57 @@ public static void main(String[] args) { try (Terminal terminal = Terminal.create()) { terminal.enableRawMode(); + printColorKinds(); printBaseColors(terminal); - Color[] palette = printPalette(terminal); + Color.RgbColor[] palette = printPalette(terminal); + demoTruecolor(); demoPaletteChange(terminal, palette); } catch (IOException e) { e.printStackTrace(); } } + // Demonstrates the three Color implementations that don't require querying the terminal: + // BasicColor (the 8 classic ANSI colors, in normal/dark/bright intensities), IndexedColor + // (the 256-color palette by index) and DefaultColor (resets to the terminal's own color). + private static void printColorKinds() { + System.out.println("BasicColor - classic ANSI colors (normal / dark / bright):"); + printBasicColorRow(Color.BasicColor.normalColors); + printBasicColorRow(Color.BasicColor.darkColors); + printBasicColorRow(Color.BasicColor.brightColors); + System.out.println(); + + System.out.println("IndexedColor - selected entries from the 256-color palette:"); + StringBuilder line = new StringBuilder(" "); + for (int index : new int[] {16, 82, 94, 129, 160, 196, 214, 226}) { + line.append(colored(Color.indexed(index), String.format("%3d", index))); + } + System.out.println(line); + System.out.println(); + + System.out.println("DefaultColor - resets foreground/background to the terminal default:"); + System.out.println( + " " + + colored(Color.BasicColor.green, "colored") + + " " + + colored(Color.DEFAULT, "default") + + " text"); + System.out.println(); + } + + private static void printBasicColorRow(Color.BasicColor[] colors) { + StringBuilder line = new StringBuilder(" "); + for (Color.BasicColor color : colors) { + line.append(colored(color, color.toString())); + } + System.out.println(line); + } + + // Wraps text in the CSI sequence for the given color's foreground style, followed by a reset. + private static String colored(Color color, String text) { + return "\u001b[" + color.toForegroundStyle() + "m" + text + "\u001b[0m "; + } + private static void printBaseColors(Terminal terminal) throws IOException { Color fg = TermColors.queryForeground(terminal, () -> terminal.read(500)); Color bg = TermColors.queryBackground(terminal, () -> terminal.read(500)); @@ -38,9 +81,9 @@ private static void printBaseColors(Terminal terminal) throws IOException { System.out.println(); } - private static Color[] printPalette(Terminal terminal) throws IOException { + private static Color.RgbColor[] printPalette(Terminal terminal) throws IOException { System.out.println("Full 256-color palette (queried via OSC 4):"); - Color[] palette = TermColors.queryPalette(terminal, () -> terminal.read(500)); + Color.RgbColor[] palette = TermColors.queryPalette(terminal, () -> terminal.read(500)); for (int row = 0; row < 16; row++) { StringBuilder line = new StringBuilder(" "); for (int col = 0; col < 16; col++) { @@ -53,15 +96,16 @@ private static Color[] printPalette(Terminal terminal) throws IOException { return palette; } - private static String swatch(int index, Color color) { + private static String swatch(int index, Color.RgbColor color) { if (color == null) { return String.format("%4d? ", index); } - return String.format( - "\u001b[48;2;%d;%d;%dm%4d\u001b[0m ", color.r8(), color.g8(), color.b8(), index); + // Use indexed color (48;5;N) instead of RGB (48;2;R;G;B) to display the actual + // palette entry as the terminal interprets it, not our queried RGB approximation. + return String.format("\u001b[48;5;%dm%4d\u001b[0m ", index, index); } - private static void demoPaletteChange(Terminal terminal, Color[] original) throws IOException { + private static void demoPaletteChange(Terminal terminal, Color.RgbColor[] original) throws IOException { System.out.println("Before: standard ANSI colors 1-6 using their current palette entries"); printSample(); @@ -69,13 +113,13 @@ private static void demoPaletteChange(Terminal terminal, Color[] original) throw System.out.println("Press Enter to swap those palette entries for a custom color scheme..."); waitForEnter(terminal); - Color[] custom = new Color[256]; - custom[1] = Color.ofRgb8(0xE0, 0x60, 0x60); // red -> soft red - custom[2] = Color.ofRgb8(0x60, 0xC0, 0x80); // green -> mint - custom[3] = Color.ofRgb8(0xE0, 0xC0, 0x60); // yellow -> gold - custom[4] = Color.ofRgb8(0x60, 0x90, 0xE0); // blue -> sky blue - custom[5] = Color.ofRgb8(0xC0, 0x70, 0xE0); // magenta -> lavender - custom[6] = Color.ofRgb8(0x50, 0xC0, 0xC0); // cyan -> teal + Color.RgbColor[] custom = new Color.RgbColor[256]; + custom[1] = Color.rgb8(0xE0, 0x60, 0x60); // red -> soft red + custom[2] = Color.rgb8(0x60, 0xC0, 0x80); // green -> mint + custom[3] = Color.rgb8(0xE0, 0xC0, 0x60); // yellow -> gold + custom[4] = Color.rgb8(0x60, 0x90, 0xE0); // blue -> sky blue + custom[5] = Color.rgb8(0xC0, 0x70, 0xE0); // magenta -> lavender + custom[6] = Color.rgb8(0x50, 0xC0, 0xC0); // cyan -> teal TermColors.setPalette(terminal, custom); System.out.println("After: same text, new palette entries"); @@ -100,7 +144,7 @@ private static void printSample() { System.out.println(line); } - private static void restoreSample(Terminal terminal, Color[] original) throws IOException { + private static void restoreSample(Terminal terminal, Color.RgbColor[] original) throws IOException { for (int index : SAMPLE_INDICES) { if (original[index] != null) { TermColors.setColor(terminal, index, original[index]); @@ -116,4 +160,50 @@ private static void waitForEnter(Terminal terminal) throws IOException { c = terminal.read(-1); } while (c != '\r' && c != '\n' && c != -1); } + + private static void demoTruecolor() { + System.out.println("Truecolor (24-bit RGB) - direct color specification:"); + System.out.println(" Gradient from red to blue:"); + StringBuilder gradient = new StringBuilder(" "); + for (int i = 0; i < 40; i++) { + int r = 255 - (i * 255 / 39); + int g = 0; + int b = (i * 255 / 39); + Color.RgbColor color = Color.rgb8(r, g, b); + gradient.append(String.format("\u001b[48;2;%d;%d;%dm ", r, g, b)); + } + gradient.append("\u001b[0m"); + System.out.println(gradient); + + System.out.println(" Rainbow spectrum:"); + StringBuilder rainbow = new StringBuilder(" "); + for (int i = 0; i < 40; i++) { + float hue = i / 40.0f; + int[] rgb = hsvToRgb(hue, 1.0f, 1.0f); + rainbow.append(String.format("\u001b[48;2;%d;%d;%dm ", rgb[0], rgb[1], rgb[2])); + } + rainbow.append("\u001b[0m"); + System.out.println(rainbow); + System.out.println(); + } + + private static int[] hsvToRgb(float h, float s, float v) { + int hi = (int) (h * 6); + float f = h * 6 - hi; + float p = v * (1 - s); + float q = v * (1 - f * s); + float t = v * (1 - (1 - f) * s); + + float r, g, b; + switch (hi % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + default: r = v; g = p; b = q; break; + } + + return new int[] {(int)(r * 255), (int)(g * 255), (int)(b * 255)}; + } }