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:
- *
- *
- * - 4 digits — used as-is
- *
- 2 digits — byte-replicated (e.g. {@code FF} → {@code FFFF})
- *
- 1 digit — nibble-replicated (e.g. {@code F} → {@code FFFF})
- *
- 3 digits — approximated as left-justified 12-bit value
- *
- *
- * @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:
+ *
+ *
+ * - 4 digits — used as-is
+ *
- 2 digits — byte-replicated (e.g. {@code FF} → {@code FFFF})
+ *
- 1 digit — nibble-replicated (e.g. {@code F} → {@code FFFF})
+ *
- 3 digits — approximated as left-justified 12-bit value
+ *
+ *
+ * @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 @@
*
*
* - {@link #setForeground} / {@link #setBackground} / {@link #setCursor} — OSC 10/11/12
- *
- {@link #setColor(Appendable, int, Color)} — single palette entry (OSC 4)
- *
- {@link #setPalette(Appendable, Color[])} — all 256 palette entries
+ *
- {@link #setColor(Appendable, int, RgbColor)} — single palette entry (OSC 4)
+ *
- {@link #setPalette(Appendable, RgbColor[])} — all 256 palette entries
*
*
* Reset methods
@@ -65,14 +66,14 @@
* Utility methods
*
*
- * - {@link #nearest(Color, Color[])} — index of the closest matching colour in a palette
+ *
- {@link #nearest(RgbColor, RgbColor[])} — index of the closest matching colour in a palette
*
*
* 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)};
+ }
}