diff --git a/terminal-api/src/main/java/org/aesh/terminal/Connection.java b/terminal-api/src/main/java/org/aesh/terminal/Connection.java index c1ba555..64d37a5 100644 --- a/terminal-api/src/main/java/org/aesh/terminal/Connection.java +++ b/terminal-api/src/main/java/org/aesh/terminal/Connection.java @@ -376,4 +376,72 @@ default TerminalColorCapability getColorCapability() { return new TerminalColorCapability(depth, TerminalColorCapability.detectThemeFromEnvironment()); } + /** + * Send an OSC (Operating System Command) query to the terminal. + *

+ * This method sends an OSC query sequence and waits for the terminal's response. + * The terminal must be actively reading input (via {@link #openBlocking()} or + * {@link #openNonBlocking()}) for this to work. + *

+ * Common OSC codes: + *

+ * + * @param oscCode the OSC code (e.g., 10 for foreground, 11 for background) + * @param param the query parameter (typically "?" for queries) + * @param timeoutMs timeout in milliseconds to wait for response + * @param responseParser function to parse the response; should return non-null + * when a complete response is received, null to continue waiting + * @param the type of the parsed response + * @return the parsed response, or null if timeout or not supported + */ + default T queryOsc(int oscCode, String param, long timeoutMs, + java.util.function.Function responseParser) { + String query = ANSI.buildOscQuery(oscCode, param); + return queryTerminal(query, timeoutMs, responseParser); + } + + /** + * Query the terminal for its foreground color using OSC 10. + *

+ * The terminal must be actively reading input for this to work. + * + * @param timeoutMs timeout in milliseconds to wait for response + * @return RGB array [r, g, b] (0-255 each), or null if not supported or timeout + */ + default int[] queryForegroundColor(long timeoutMs) { + return queryOsc(ANSI.OSC_FOREGROUND, "?", timeoutMs, + input -> ANSI.parseOscColorResponse(input, ANSI.OSC_FOREGROUND)); + } + + /** + * Query the terminal for its background color using OSC 11. + *

+ * The terminal must be actively reading input for this to work. + * + * @param timeoutMs timeout in milliseconds to wait for response + * @return RGB array [r, g, b] (0-255 each), or null if not supported or timeout + */ + default int[] queryBackgroundColor(long timeoutMs) { + return queryOsc(ANSI.OSC_BACKGROUND, "?", timeoutMs, + input -> ANSI.parseOscColorResponse(input, ANSI.OSC_BACKGROUND)); + } + + /** + * Query the terminal for its cursor color using OSC 12. + *

+ * The terminal must be actively reading input for this to work. + * + * @param timeoutMs timeout in milliseconds to wait for response + * @return RGB array [r, g, b] (0-255 each), or null if not supported or timeout + */ + default int[] queryCursorColor(long timeoutMs) { + return queryOsc(ANSI.OSC_CURSOR_COLOR, "?", timeoutMs, + input -> ANSI.parseOscColorResponse(input, ANSI.OSC_CURSOR_COLOR)); + } + } diff --git a/terminal-api/src/main/java/org/aesh/terminal/utils/ANSI.java b/terminal-api/src/main/java/org/aesh/terminal/utils/ANSI.java index 9d10526..2ba76b5 100644 --- a/terminal-api/src/main/java/org/aesh/terminal/utils/ANSI.java +++ b/terminal-api/src/main/java/org/aesh/terminal/utils/ANSI.java @@ -122,6 +122,20 @@ public class ANSI { /** ANSI escape code to enable dark (normal) background mode. */ public static final String DARK_BG = "\u001B[?5l"; + /** OSC (Operating System Command) escape sequence start. */ + public static final String OSC_START = "\u001B]"; + /** BEL character, used as OSC terminator. */ + public static final String BEL = "\u0007"; + /** ST (String Terminator), alternate OSC terminator. */ + public static final String ST = "\u001B\\"; + + /** OSC code for foreground color query/set. */ + public static final int OSC_FOREGROUND = 10; + /** OSC code for background color query/set. */ + public static final int OSC_BACKGROUND = 11; + /** OSC code for cursor color query/set. */ + public static final int OSC_CURSOR_COLOR = 12; + private ANSI() { } @@ -295,4 +309,105 @@ else if (value > 999 && value < 9999) return 5; } + /** + * Build an OSC (Operating System Command) query string. + *

+ * OSC format: ESC ] Ps ; Pt BEL + * Where Ps is the OSC code and Pt is the parameter. + * + * @param oscCode the OSC code (e.g., 10 for foreground, 11 for background) + * @param param the parameter (e.g., "?" for query) + * @return the OSC query string + */ + public static String buildOscQuery(int oscCode, String param) { + return OSC_START + oscCode + ";" + param + BEL; + } + + /** + * Parse an OSC color response. + *

+ * Expected format: ESC ] {oscCode} ; rgb:RRRR/GGGG/BBBB {ST} + * Where: + *

    + *
  • ESC is 0x1B (27)
  • + *
  • oscCode is the OSC code (e.g., 10 for foreground, 11 for background)
  • + *
  • RRRR, GGGG, BBBB are 4-digit or 2-digit hex values
  • + *
  • ST is either BEL (0x07) or ESC \ (0x1B 0x5C)
  • + *
+ * + * @param input the input sequence as code points + * @param oscCode the expected OSC code in response + * @return RGB array [r, g, b] (0-255 each), or null if parsing failed + */ + public static int[] parseOscColorResponse(int[] input, int oscCode) { + if (input == null || input.length < 10) { + return null; + } + + // Convert to string for easier parsing + StringBuilder sb = new StringBuilder(); + for (int c : input) { + sb.appendCodePoint(c); + } + String response = sb.toString(); + + // Look for the OSC response pattern + // Format: ESC ] {code} ; rgb:RRRR/GGGG/BBBB {terminator} + int start = response.indexOf("\u001B]" + oscCode + ";rgb:"); + if (start < 0) { + // Try alternate format with just ']' + start = response.indexOf("]" + oscCode + ";rgb:"); + if (start >= 0 && start > 0 && response.charAt(start - 1) == '\u001B') { + start--; + } else if (start < 0) { + return null; + } + } + + // Extract the rgb: part + int rgbStart = response.indexOf("rgb:", start); + if (rgbStart < 0) { + return null; + } + rgbStart += 4; // skip "rgb:" + + // Find the terminator (BEL or ESC \) + int end = response.indexOf('\u0007', rgbStart); + if (end < 0) { + end = response.indexOf("\u001B\\", rgbStart); + } + if (end < 0) { + end = response.length(); + } + + String rgbPart = response.substring(rgbStart, end); + + // Parse RRRR/GGGG/BBBB + String[] parts = rgbPart.split("/"); + if (parts.length != 3) { + return null; + } + + try { + int[] rgb = new int[3]; + for (int i = 0; i < 3; i++) { + String hex = parts[i].trim(); + int value; + if (hex.length() == 4) { + // 4-digit hex (e.g., FFFF), take high byte + value = Integer.parseInt(hex, 16) >> 8; + } else if (hex.length() == 2) { + // 2-digit hex + value = Integer.parseInt(hex, 16); + } else { + return null; + } + rgb[i] = Math.min(255, Math.max(0, value)); + } + return rgb; + } catch (NumberFormatException e) { + return null; + } + } + } diff --git a/terminal-api/src/test/java/org/aesh/terminal/ConnectionOscQueryTest.java b/terminal-api/src/test/java/org/aesh/terminal/ConnectionOscQueryTest.java new file mode 100644 index 0000000..757cdd9 --- /dev/null +++ b/terminal-api/src/test/java/org/aesh/terminal/ConnectionOscQueryTest.java @@ -0,0 +1,407 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.terminal; + +import static org.junit.Assert.*; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.aesh.terminal.tty.Capability; +import org.aesh.terminal.tty.Signal; +import org.aesh.terminal.tty.Size; +import org.junit.Test; + +/** + * Tests for the Connection.queryOsc() method and related color query methods. + * These tests verify that OSC queries work correctly and responses are captured + * properly (addressing issue #94). + * + * @author Ståle Pedersen + */ +public class ConnectionOscQueryTest { + + /** + * Test that queryOsc correctly builds and sends OSC queries. + */ + @Test + public void testQueryOscBuildsCorrectQuery() { + List sentQueries = new ArrayList<>(); + MockConnection connection = new MockConnection() { + @Override + public Consumer stdoutHandler() { + return codePoints -> { + StringBuilder sb = new StringBuilder(); + for (int cp : codePoints) { + sb.appendCodePoint(cp); + } + sentQueries.add(sb.toString()); + }; + } + }; + + // Trigger a query (will timeout since no response) + connection.queryOsc(10, "?", 50, input -> null); + + assertEquals(1, sentQueries.size()); + assertEquals("\u001B]10;?\u0007", sentQueries.get(0)); + } + + /** + * Test that queryForegroundColor correctly parses a valid response. + * This tests the scenario from issue #94 - the response should be + * captured and returned, not echoed to terminal. + */ + @Test + public void testQueryForegroundColorReturnsValue() throws Exception { + // Create a connection that simulates a terminal responding to OSC 10 + MockConnection connection = new MockConnection(); + + // Simulate the terminal response in a separate thread + CountDownLatch queryStarted = new CountDownLatch(1); + Thread responseThread = new Thread(() -> { + try { + queryStarted.await(1, TimeUnit.SECONDS); + Thread.sleep(20); // Give time for query to be sent + // Simulate terminal response + String response = "\u001B]10;rgb:FFFF/8080/0000\u0007"; + connection.simulateInput(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + responseThread.start(); + + // Signal that query is starting + queryStarted.countDown(); + + // Query foreground color + int[] rgb = connection.queryForegroundColor(500); + + responseThread.join(1000); + + // Verify the response was captured and returned + assertNotNull("queryForegroundColor should return the parsed RGB values", rgb); + assertEquals(3, rgb.length); + assertEquals(255, rgb[0]); // FFFF >> 8 = 255 + assertEquals(128, rgb[1]); // 8080 >> 8 = 128 + assertEquals(0, rgb[2]); // 0000 >> 8 = 0 + } + + /** + * Test that queryBackgroundColor correctly parses a valid response. + */ + @Test + public void testQueryBackgroundColorReturnsValue() throws Exception { + MockConnection connection = new MockConnection(); + + CountDownLatch queryStarted = new CountDownLatch(1); + Thread responseThread = new Thread(() -> { + try { + queryStarted.await(1, TimeUnit.SECONDS); + Thread.sleep(20); + String response = "\u001B]11;rgb:2828/2828/2828\u0007"; + connection.simulateInput(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + responseThread.start(); + + queryStarted.countDown(); + int[] rgb = connection.queryBackgroundColor(500); + responseThread.join(1000); + + assertNotNull("queryBackgroundColor should return the parsed RGB values", rgb); + assertEquals(40, rgb[0]); // 2828 >> 8 = 40 + assertEquals(40, rgb[1]); + assertEquals(40, rgb[2]); + } + + /** + * Test that queryCursorColor correctly parses a valid response. + */ + @Test + public void testQueryCursorColorReturnsValue() throws Exception { + MockConnection connection = new MockConnection(); + + CountDownLatch queryStarted = new CountDownLatch(1); + Thread responseThread = new Thread(() -> { + try { + queryStarted.await(1, TimeUnit.SECONDS); + Thread.sleep(20); + String response = "\u001B]12;rgb:0000/FFFF/0000\u0007"; + connection.simulateInput(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + responseThread.start(); + + queryStarted.countDown(); + int[] rgb = connection.queryCursorColor(500); + responseThread.join(1000); + + assertNotNull("queryCursorColor should return the parsed RGB values", rgb); + assertEquals(0, rgb[0]); + assertEquals(255, rgb[1]); + assertEquals(0, rgb[2]); + } + + /** + * Test that query returns null on timeout when no response. + */ + @Test + public void testQueryReturnsNullOnTimeout() { + MockConnection connection = new MockConnection(); + + long start = System.currentTimeMillis(); + int[] rgb = connection.queryForegroundColor(100); + long elapsed = System.currentTimeMillis() - start; + + assertNull("Should return null when no response received", rgb); + assertTrue("Should wait for timeout", elapsed >= 90); + } + + /** + * Test that the response is captured by the stdin handler and not + * passed to any other handler (addressing issue #94). + */ + @Test + public void testResponseNotPassedToOriginalHandler() throws Exception { + MockConnection connection = new MockConnection(); + List originalHandlerReceived = new ArrayList<>(); + + // Set up an original handler that should NOT receive the OSC response + connection.setStdinHandler(input -> { + StringBuilder sb = new StringBuilder(); + for (int cp : input) { + sb.appendCodePoint(cp); + } + originalHandlerReceived.add(sb.toString()); + }); + + CountDownLatch queryStarted = new CountDownLatch(1); + Thread responseThread = new Thread(() -> { + try { + queryStarted.await(1, TimeUnit.SECONDS); + Thread.sleep(20); + String response = "\u001B]10;rgb:FFFF/FFFF/FFFF\u0007"; + connection.simulateInput(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + responseThread.start(); + + queryStarted.countDown(); + int[] rgb = connection.queryForegroundColor(500); + responseThread.join(1000); + + assertNotNull("Query should succeed", rgb); + // The original handler should be restored after the query + // but should not have received the OSC response + assertTrue("Original handler should not receive OSC response during query", + originalHandlerReceived.isEmpty()); + } + + /** + * Test generic queryOsc with custom parser. + */ + @Test + public void testGenericQueryOscWithCustomParser() throws Exception { + MockConnection connection = new MockConnection(); + + CountDownLatch queryStarted = new CountDownLatch(1); + Thread responseThread = new Thread(() -> { + try { + queryStarted.await(1, TimeUnit.SECONDS); + Thread.sleep(20); + // Simulate a custom OSC response + String response = "\u001B]52;c;SGVsbG8gV29ybGQ=\u0007"; + connection.simulateInput(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + responseThread.start(); + + queryStarted.countDown(); + + // Query OSC 52 (clipboard) with custom parser + String result = connection.queryOsc(52, "c;?", 500, input -> { + StringBuilder sb = new StringBuilder(); + for (int cp : input) { + sb.appendCodePoint(cp); + } + String str = sb.toString(); + if (str.contains(";") && str.contains("\u001B]52")) { + // Extract base64 content + int start = str.indexOf(";c;") + 3; + int end = str.indexOf('\u0007', start); + if (end < 0) + end = str.indexOf("\u001B\\", start); + if (end > start) { + return str.substring(start, end); + } + } + return null; + }); + + responseThread.join(1000); + + assertEquals("SGVsbG8gV29ybGQ=", result); + } + + /** + * A mock connection for testing OSC queries. + */ + private static class MockConnection implements Connection { + private Consumer stdinHandler; + private Consumer sizeHandler; + private Consumer signalHandler; + private Consumer closeHandler; + private Attributes attributes = new Attributes(); + private final List outputBuffer = new ArrayList<>(); + + @Override + public Device device() { + return new BaseDevice("xterm-256color"); + } + + @Override + public Size size() { + return new Size(80, 24); + } + + @Override + public Consumer getSizeHandler() { + return sizeHandler; + } + + @Override + public void setSizeHandler(Consumer handler) { + this.sizeHandler = handler; + } + + @Override + public Consumer getSignalHandler() { + return signalHandler; + } + + @Override + public void setSignalHandler(Consumer handler) { + this.signalHandler = handler; + } + + @Override + public Consumer getStdinHandler() { + return stdinHandler; + } + + @Override + public void setStdinHandler(Consumer handler) { + this.stdinHandler = handler; + } + + @Override + public Consumer stdoutHandler() { + return codePoints -> { + StringBuilder sb = new StringBuilder(); + for (int cp : codePoints) { + sb.appendCodePoint(cp); + } + outputBuffer.add(sb.toString()); + }; + } + + @Override + public void setCloseHandler(Consumer handler) { + this.closeHandler = handler; + } + + @Override + public Consumer getCloseHandler() { + return closeHandler; + } + + @Override + public void close() { + if (closeHandler != null) { + closeHandler.accept(null); + } + } + + @Override + public void openBlocking() { + } + + @Override + public void openNonBlocking() { + } + + @Override + public boolean put(Capability capability, Object... params) { + return false; + } + + @Override + public Attributes getAttributes() { + return attributes; + } + + @Override + public void setAttributes(Attributes attr) { + this.attributes = attr; + } + + @Override + public Charset inputEncoding() { + return Charset.defaultCharset(); + } + + @Override + public Charset outputEncoding() { + return Charset.defaultCharset(); + } + + @Override + public boolean supportsAnsi() { + return true; + } + + /** + * Simulate terminal input (as if the terminal responded to a query). + */ + public void simulateInput(String input) { + if (stdinHandler != null) { + stdinHandler.accept(input.codePoints().toArray()); + } + } + + public List getOutputBuffer() { + return outputBuffer; + } + } +} diff --git a/terminal-api/src/test/java/org/aesh/terminal/utils/ANSIOscTest.java b/terminal-api/src/test/java/org/aesh/terminal/utils/ANSIOscTest.java new file mode 100644 index 0000000..389258e --- /dev/null +++ b/terminal-api/src/test/java/org/aesh/terminal/utils/ANSIOscTest.java @@ -0,0 +1,232 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.terminal.utils; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Tests for OSC (Operating System Command) utilities in the ANSI class. + * These tests verify the parsing and building of OSC sequences used for + * terminal color queries. + * + * @author Ståle Pedersen + */ +public class ANSIOscTest { + + @Test + public void testOscConstants() { + assertEquals("\u001B]", ANSI.OSC_START); + assertEquals("\u0007", ANSI.BEL); + assertEquals("\u001B\\", ANSI.ST); + assertEquals(10, ANSI.OSC_FOREGROUND); + assertEquals(11, ANSI.OSC_BACKGROUND); + assertEquals(12, ANSI.OSC_CURSOR_COLOR); + } + + @Test + public void testBuildOscQuery() { + // Test foreground color query (OSC 10) + String query = ANSI.buildOscQuery(10, "?"); + assertEquals("\u001B]10;?\u0007", query); + + // Test background color query (OSC 11) + query = ANSI.buildOscQuery(11, "?"); + assertEquals("\u001B]11;?\u0007", query); + + // Test cursor color query (OSC 12) + query = ANSI.buildOscQuery(12, "?"); + assertEquals("\u001B]12;?\u0007", query); + + // Test with different parameter + query = ANSI.buildOscQuery(10, "rgb:ffff/ffff/ffff"); + assertEquals("\u001B]10;rgb:ffff/ffff/ffff\u0007", query); + } + + @Test + public void testParseOscColorResponse_ForegroundWith4DigitHex() { + // Standard OSC 10 response with 4-digit hex values (common format) + // Response: ESC ] 10 ; rgb:FFFF/8080/0000 BEL + String response = "\u001B]10;rgb:FFFF/8080/0000\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNotNull("Should parse valid OSC 10 response", rgb); + assertEquals(3, rgb.length); + assertEquals(255, rgb[0]); // FFFF >> 8 = 255 + assertEquals(128, rgb[1]); // 8080 >> 8 = 128 + assertEquals(0, rgb[2]); // 0000 >> 8 = 0 + } + + @Test + public void testParseOscColorResponse_BackgroundWith4DigitHex() { + // Standard OSC 11 response with 4-digit hex values + String response = "\u001B]11;rgb:2828/2828/2828\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 11); + + assertNotNull("Should parse valid OSC 11 response", rgb); + assertEquals(3, rgb.length); + assertEquals(40, rgb[0]); // 2828 >> 8 = 40 + assertEquals(40, rgb[1]); + assertEquals(40, rgb[2]); + } + + @Test + public void testParseOscColorResponse_With2DigitHex() { + // Some terminals respond with 2-digit hex values + String response = "\u001B]10;rgb:FF/80/00\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNotNull("Should parse 2-digit hex response", rgb); + assertEquals(255, rgb[0]); + assertEquals(128, rgb[1]); + assertEquals(0, rgb[2]); + } + + @Test + public void testParseOscColorResponse_WithSTTerminator() { + // Some terminals use ESC \ as terminator instead of BEL + String response = "\u001B]10;rgb:FFFF/0000/FFFF\u001B\\"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNotNull("Should parse response with ST terminator", rgb); + assertEquals(255, rgb[0]); + assertEquals(0, rgb[1]); + assertEquals(255, rgb[2]); + } + + @Test + public void testParseOscColorResponse_CursorColor() { + // OSC 12 for cursor color + String response = "\u001B]12;rgb:0000/FFFF/0000\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 12); + + assertNotNull("Should parse cursor color response", rgb); + assertEquals(0, rgb[0]); + assertEquals(255, rgb[1]); + assertEquals(0, rgb[2]); + } + + @Test + public void testParseOscColorResponse_WrongOscCode() { + // Response is for OSC 11 but we're looking for OSC 10 + String response = "\u001B]11;rgb:FFFF/FFFF/FFFF\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNull("Should return null for wrong OSC code", rgb); + } + + @Test + public void testParseOscColorResponse_InvalidFormat() { + // Missing rgb: prefix + String response = "\u001B]10;FFFF/FFFF/FFFF\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNull("Should return null for invalid format", rgb); + } + + @Test + public void testParseOscColorResponse_NullInput() { + int[] rgb = ANSI.parseOscColorResponse(null, 10); + assertNull("Should return null for null input", rgb); + } + + @Test + public void testParseOscColorResponse_EmptyInput() { + int[] rgb = ANSI.parseOscColorResponse(new int[0], 10); + assertNull("Should return null for empty input", rgb); + } + + @Test + public void testParseOscColorResponse_TooShortInput() { + int[] rgb = ANSI.parseOscColorResponse(new int[] { 27, ']', '1', '0' }, 10); + assertNull("Should return null for too short input", rgb); + } + + @Test + public void testParseOscColorResponse_WithPrefixNoise() { + // Sometimes there's noise before the actual response + String response = "some noise\u001B]10;rgb:8080/8080/8080\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNotNull("Should parse response with prefix noise", rgb); + assertEquals(128, rgb[0]); + assertEquals(128, rgb[1]); + assertEquals(128, rgb[2]); + } + + @Test + public void testParseOscColorResponse_WhiteColor() { + // Test parsing white color (max values) + String response = "\u001B]11;rgb:FFFF/FFFF/FFFF\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 11); + + assertNotNull(rgb); + assertEquals(255, rgb[0]); + assertEquals(255, rgb[1]); + assertEquals(255, rgb[2]); + } + + @Test + public void testParseOscColorResponse_BlackColor() { + // Test parsing black color (min values) + String response = "\u001B]11;rgb:0000/0000/0000\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 11); + + assertNotNull(rgb); + assertEquals(0, rgb[0]); + assertEquals(0, rgb[1]); + assertEquals(0, rgb[2]); + } + + @Test + public void testParseOscColorResponse_LowercaseHex() { + // Some terminals might respond with lowercase hex + String response = "\u001B]10;rgb:abcd/ef01/2345\u0007"; + int[] input = response.codePoints().toArray(); + + int[] rgb = ANSI.parseOscColorResponse(input, 10); + + assertNotNull("Should parse lowercase hex", rgb); + assertEquals(0xab, rgb[0]); // abcd >> 8 = 0xab = 171 + assertEquals(0xef, rgb[1]); // ef01 >> 8 = 0xef = 239 + assertEquals(0x23, rgb[2]); // 2345 >> 8 = 0x23 = 35 + } +} diff --git a/terminal-tty/src/main/java/org/aesh/terminal/tty/TerminalColorDetector.java b/terminal-tty/src/main/java/org/aesh/terminal/tty/TerminalColorDetector.java index 99d656d..3debd8a 100644 --- a/terminal-tty/src/main/java/org/aesh/terminal/tty/TerminalColorDetector.java +++ b/terminal-tty/src/main/java/org/aesh/terminal/tty/TerminalColorDetector.java @@ -20,9 +20,6 @@ package org.aesh.terminal.tty; import java.io.IOException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; @@ -1591,7 +1588,10 @@ private static TerminalTheme parseJetBrainsColorScheme(java.io.File colorsFile) * @return RGB array [r, g, b] (0-255 each), or null if not supported */ public static int[] queryForegroundColor(Connection connection, long timeoutMs) { - return queryOscColor(connection, OSC_QUERY_FOREGROUND, 10, timeoutMs); + if (connection == null) { + return null; + } + return connection.queryForegroundColor(timeoutMs); } /** @@ -1602,7 +1602,10 @@ public static int[] queryForegroundColor(Connection connection, long timeoutMs) * @return RGB array [r, g, b] (0-255 each), or null if not supported */ public static int[] queryBackgroundColor(Connection connection, long timeoutMs) { - return queryOscColor(connection, OSC_QUERY_BACKGROUND, 11, timeoutMs); + if (connection == null) { + return null; + } + return connection.queryBackgroundColor(timeoutMs); } /** @@ -2102,138 +2105,6 @@ private static int[] parseOscColorFromString(String response, int oscCode) { } } - /** - * Query an OSC color from the terminal. - * - * @param connection the terminal connection - * @param query the OSC query sequence - * @param oscCode the expected OSC code in response (10 or 11) - * @param timeoutMs timeout in milliseconds - * @return RGB array [r, g, b] (0-255 each), or null if not supported - */ - private static int[] queryOscColor(Connection connection, String query, int oscCode, long timeoutMs) { - if (connection == null || !connection.supportsAnsi()) { - return null; - } - - CountDownLatch latch = new CountDownLatch(1); - final int[][] result = { null }; - - Consumer prevHandler = connection.getStdinHandler(); - connection.setStdinHandler(input -> { - int[] parsed = parseOscColorResponse(input, oscCode); - if (parsed != null) { - result[0] = parsed; - latch.countDown(); - } - }); - - try { - // Send the query - connection.stdoutHandler().accept(query.codePoints().toArray()); - - // Wait for response with timeout - if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) { - LOGGER.log(Level.FINE, "Timeout waiting for OSC color response"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOGGER.log(Level.FINE, "Interrupted while waiting for OSC color response", e); - } finally { - // Restore previous handler - connection.setStdinHandler(prevHandler); - } - - return result[0]; - } - - /** - * Parse an OSC color response. - *

- * Expected format: ESC ] {oscCode} ; rgb:RRRR/GGGG/BBBB {ST} - * Where: - *

    - *
  • ESC is 0x1B (27)
  • - *
  • oscCode is 10 (foreground) or 11 (background)
  • - *
  • RRRR, GGGG, BBBB are 4-digit hex values
  • - *
  • ST is either BEL (0x07) or ESC \ (0x1B 0x5C)
  • - *
- * - * @param input the input sequence - * @param oscCode the expected OSC code - * @return RGB array [r, g, b] (0-255 each), or null if parsing failed - */ - static int[] parseOscColorResponse(int[] input, int oscCode) { - if (input == null || input.length < 10) { - return null; - } - - // Convert to string for easier parsing - StringBuilder sb = new StringBuilder(); - for (int c : input) { - sb.appendCodePoint(c); - } - String response = sb.toString(); - - // Look for the OSC response pattern - // Format: ESC ] {code} ; rgb:RRRR/GGGG/BBBB {terminator} - int start = response.indexOf("\u001B]" + oscCode + ";rgb:"); - if (start < 0) { - // Try alternate format with just ']' - start = response.indexOf("]" + oscCode + ";rgb:"); - if (start >= 0 && start > 0 && response.charAt(start - 1) == '\u001B') { - start--; - } else if (start < 0) { - return null; - } - } - - // Extract the rgb: part - int rgbStart = response.indexOf("rgb:", start); - if (rgbStart < 0) { - return null; - } - - // Find the terminator (BEL or ESC \) - int end = response.indexOf('\u0007', rgbStart); - if (end < 0) { - end = response.indexOf("\u001B\\", rgbStart); - } - if (end < 0) { - end = response.length(); - } - - String rgbPart = response.substring(rgbStart + 4, end); - - // Parse RRRR/GGGG/BBBB - String[] parts = rgbPart.split("/"); - if (parts.length != 3) { - return null; - } - - try { - int[] rgb = new int[3]; - for (int i = 0; i < 3; i++) { - String hex = parts[i].trim(); - int value; - if (hex.length() == 4) { - // 4-digit hex (e.g., FFFF), take high byte - value = Integer.parseInt(hex, 16) >> 8; - } else if (hex.length() == 2) { - // 2-digit hex - value = Integer.parseInt(hex, 16); - } else { - return null; - } - rgb[i] = Math.min(255, Math.max(0, value)); - } - return rgb; - } catch (NumberFormatException e) { - LOGGER.log(Level.FINE, "Failed to parse OSC color response: " + rgbPart, e); - return null; - } - } - /** * Check if the terminal likely supports OSC color queries. *