diff --git a/examples/PrintCaps.java b/examples/PrintCaps.java index db21cc3..febfa95 100755 --- a/examples/PrintCaps.java +++ b/examples/PrintCaps.java @@ -12,6 +12,8 @@ public class PrintCaps { public static void main(String[] args) { + boolean force = args.length > 0 && "--force".equals(args[0]); + TermCaps detected = TermCaps.detect(); System.out.println("Terminal capabilities (detected from environment):"); printCaps(detected); @@ -22,25 +24,95 @@ public static void main(String[] args) { terminal.enableRawMode(); TermCaps probed = TermProber.probe(terminal, () -> terminal.read(500)); printCaps(probed); + + System.out.println(); + testStyles(probed, force); } catch (IOException e) { System.out.println(" (query failed: " + e.getMessage() + ")"); } } private static void printCaps(TermCaps caps) { - System.out.println(" colors : " + caps.colors()); - System.out.println(" altScreen : " + caps.altScreen()); - System.out.println(" mouse : " + caps.mouse()); - System.out.println(" bracketedPaste : " + caps.bracketedPaste()); - System.out.println(" focusTracking : " + caps.focusTracking()); - System.out.println(" hyperlinks : " + caps.hyperlinks()); - System.out.println(" settableTitle : " + caps.settableTitle()); - System.out.println(" unicode : " + caps.unicode()); - System.out.println(" italic : " + caps.italic()); - System.out.println(" strikethrough : " + caps.strikethrough()); - System.out.println(" overline : " + caps.overline()); - System.out.println(" sixel : " + caps.sixel()); - System.out.println(" kittyGraphics : " + caps.kittyGraphics()); - System.out.println(" iterm2Images : " + caps.iterm2Images()); + System.out.println(" colors : " + caps.colors()); + System.out.println(" altScreen : " + caps.altScreen()); + System.out.println(" mouse : " + caps.mouse()); + System.out.println(" bracketedPaste : " + caps.bracketedPaste()); + System.out.println(" focusTracking : " + caps.focusTracking()); + System.out.println(" synchronizedOutput : " + caps.synchronizedOutput()); + System.out.println(" hyperlinks : " + caps.hyperlinks()); + System.out.println(" settableTitle : " + caps.settableTitle()); + System.out.println(" unicode : " + caps.unicode()); + System.out.println(" italic : " + caps.italic()); + System.out.println(" strikethrough : " + caps.strikethrough()); + System.out.println(" overline : " + caps.overline()); + System.out.println(" undercurl : " + caps.undercurl()); + System.out.println(" extendedUnderline : " + caps.extendedUnderline()); + System.out.println(" sixel : " + caps.sixel()); + System.out.println(" kittyGraphics : " + caps.kittyGraphics()); + System.out.println(" iterm2Images : " + caps.iterm2Images()); + System.out.println(" kittyKeyboard : " + caps.kittyKeyboard()); + } + + private static void testStyles(TermCaps caps, boolean force) { + System.out.println(" Testing text styles:"); + + // Italic + if (force || caps.italic()) { + System.out.println(" \u001b[3mItalic text\u001b[23m" + (force ? "" : " (supported)")); + } else { + System.out.println(" Italic text (not supported)"); + } + + // Strikethrough + if (force || caps.strikethrough()) { + System.out.println(" \u001b[9mStrikethrough text\u001b[29m" + (force ? "" : " (supported)")); + } else { + System.out.println(" Strikethrough text (not supported)"); + } + + // Overline + if (force || caps.overline()) { + System.out.println(" \u001b[53mOverline text\u001b[55m" + (force ? "" : " (supported)")); + } else { + System.out.println(" Overline text (not supported)"); + } + + // Undercurl + if (force || caps.undercurl()) { + System.out.println(" \u001b[4:3mUndercurl text\u001b[4:0m" + (force ? "" : " (supported)")); + } else { + System.out.println(" Undercurl text (not supported)"); + } + + // Extended underline styles + if (force || caps.extendedUnderline()) { + System.out.println(" \u001b[4:1mSingle underline\u001b[4:0m" + (force ? "" : " (supported)")); + System.out.println(" \u001b[4:2mDouble underline\u001b[4:0m" + (force ? "" : " (supported)")); + System.out.println(" \u001b[4:4mDotted underline\u001b[4:0m" + (force ? "" : " (supported)")); + System.out.println(" \u001b[4:5mDashed underline\u001b[4:0m" + (force ? "" : " (supported)")); + System.out.println(" \u001b[4:1m\u001b[58:2::255:0:0mRed underline (RGB)\u001b[59m\u001b[4:0m" + (force ? "" : " (supported)")); + System.out.println(" \u001b[4:1m\u001b[58:5:46mGreen underline (color 46)\u001b[59m\u001b[4:0m" + (force ? "" : " (supported)")); + } else { + System.out.println(" Extended underline styles (not supported)"); + } + + // Unicode + if (force || caps.unicode()) { + System.out.println(" Unicode: ✓ ★ ♥ ☺ → ⇒ ≈ ≠ ∞" + (force ? "" : " (supported)")); + } else { + System.out.println(" Unicode (not supported)"); + } + + // Hyperlinks + if (force || caps.hyperlinks()) { + System.out.println(" \u001b]8;;https://github.com/codejive/java-miniterm\u001b\\Hyperlink example\u001b]8;;\u001b\\" + (force ? "" : " (supported)")); + } else { + System.out.println(" Hyperlink example (not supported)"); + } + + System.out.println(); + if (!force) { + System.out.println(" (Use --force to test all styles regardless of detected capabilities)"); + } } } diff --git a/examples/PrintKittyKB.java b/examples/PrintKittyKB.java new file mode 100644 index 0000000..1b6809f --- /dev/null +++ b/examples/PrintKittyKB.java @@ -0,0 +1,172 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS org.codejive.miniterm:miniterm${miniterm.ffm:}:${miniterm.version:0.1.7} +//DEPS org.codejive.miniterm:ansiparser:${miniterm.version:0.1.7} +//DEPS org.codejive.miniterm:termcap:${miniterm.version:0.1.7} + +package examples; + +import java.io.IOException; +import org.codejive.miniterm.Terminal; +import org.codejive.miniterm.ansiparser.AnsiReader; +import org.codejive.miniterm.termcap.TermCaps; +import org.codejive.miniterm.termcap.TermProber; + +public class PrintKittyKB { + + // ANSI helpers + private static final String CSI = "\033["; + + public static void main(String[] args) { + try (Terminal terminal = Terminal.create()) { + terminal.enableRawMode(); + + // Detect terminal capabilities + TermCaps caps = TermProber.probe(terminal, () -> terminal.read(500)); + + System.out.println("Terminal capabilities:"); + System.out.println(" Kitty keyboard support: " + caps.kittyKeyboard()); + System.out.println(); + + if (!caps.kittyKeyboard()) { + System.out.println("Warning: Terminal does not report Kitty keyboard support."); + System.out.println("Attempting to enable anyway (some terminals support it without reporting)."); + System.out.println(); + } + + // Query terminal for Kitty keyboard protocol support + System.out.println("Querying terminal for Kitty keyboard protocol..."); + TermCaps.queryKittyKeyboard(terminal); + + // Try to read the response + AnsiReader reader = new AnsiReader(() -> terminal.read(500)); + String response = reader.read(); + if (response != null && TermCaps.isKittyKeyboardResponse(response)) { + System.out.println("Terminal responded: " + escape(response)); + System.out.println("Kitty keyboard protocol is supported!"); + } else { + System.out.println("No response received (timeout or not supported)."); + } + System.out.println(); + + // Enable Kitty keyboard protocol with flags: + // 1 = Disambiguate escape codes + // 2 = Report event types (press, release, repeat) + // 4 = Report alternate keys + // Combined: 7 = Full enhancement + int flags = 7; + System.out.println("Enabling Kitty keyboard protocol with flags: " + flags); + System.out.println(" 1 = Disambiguate escape codes"); + System.out.println(" 2 = Report event types (press, release, repeat)"); + System.out.println(" 4 = Report alternate keys"); + System.out.println(); + + TermCaps.pushKittyKeyboard(terminal, flags); + + System.out.println(); + System.out.println("Press keys to see enhanced keyboard events."); + System.out.println("Try: letters (plain text), Ctrl+Shift+A, Alt+Shift+B, arrow keys."); + System.out.println("(Press Ctrl+C to exit)"); + System.out.println(); + System.out.println(String.format("%-40s | %-30s | %s", "Raw", "Hex", "Type")); + System.out.println(String.format("%-40s-+-%-30s-+-%s", + "-".repeat(40), "-".repeat(30), "-".repeat(30))); + + try { + String token; + while ((token = reader.read()) != null) { + if (token.isEmpty()) continue; + + // Check for Ctrl+C (raw byte 3 or Kitty protocol CSI 99;5u) + if (!token.startsWith("\033") && token.length() == 1 && token.charAt(0) == 3) { + break; + } + // Check for Kitty-encoded Ctrl+C: CSI 99;5u (ESC[99;5u) + if ((CSI + "99;5u").equals(token)) { + break; + } + + // Print the event details on a single line + String type = analyzeToken(token); + System.out.println(String.format("%-40s | %-30s | %s", + escape(token), toHex(token), type)); + } + } finally { + // Restore original keyboard protocol + TermCaps.popKittyKeyboard(terminal); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + + + private static String analyzeToken(String token) { + if (token == null || token.isEmpty()) { + return "Empty"; + } + + // Check for Kitty keyboard protocol sequences + // Format: CSI : ; : ; u + if (token.startsWith(CSI) && token.endsWith("u")) { + return "Kitty keyboard protocol sequence"; + } + + // Check for standard CSI sequences + if (token.startsWith(CSI)) { + return "CSI sequence (standard)"; + } + + // Check for escape sequences + if (token.startsWith("\033")) { + return "Escape sequence"; + } + + // Check for control characters + if (token.length() == 1) { + char c = token.charAt(0); + if (c < 32) { + return "Control character (Ctrl+" + (char) (c + 64) + ")"; + } + if (c == 127) { + return "DEL character"; + } + return "Printable character: '" + c + "'"; + } + + // Multi-byte sequence + return "Multi-byte sequence"; + } + + private static String escape(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\033') { + sb.append("ESC"); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c < 32 || c == 127) { + sb.append("\\x").append(String.format("%02x", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String toHex(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + if (i > 0) sb.append(" "); + sb.append(String.format("%02x", (int) s.charAt(i))); + } + return sb.toString(); + } +} diff --git a/examples/PrintPaste.java b/examples/PrintPaste.java new file mode 100644 index 0000000..08d729f --- /dev/null +++ b/examples/PrintPaste.java @@ -0,0 +1,98 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS org.codejive.miniterm:miniterm${miniterm.ffm:}:${miniterm.version:0.1.7} +//DEPS org.codejive.miniterm:ansiparser:${miniterm.version:0.1.7} + +package examples; + +import java.io.IOException; +import org.codejive.miniterm.Terminal; +import org.codejive.miniterm.ansiparser.AnsiParser; + +public class PrintPaste { + private static final String ENABLE_BRACKETED_PASTE = "\u001b[?2004h"; + private static final String DISABLE_BRACKETED_PASTE = "\u001b[?2004l"; + private static final String PASTE_START = "\u001b[200~"; + private static final String PASTE_END = "\u001b[201~"; + + public static void main(String[] args) { + try (Terminal terminal = Terminal.create()) { + terminal.enableRawMode(); + terminal.write(ENABLE_BRACKETED_PASTE); + System.out.println("Bracketed paste mode enabled."); + System.out.println("Try pasting text (Ctrl+V or right-click paste)."); + System.out.println("Press Ctrl+C to exit.\n"); + + boolean inPaste = false; + StringBuilder pasteBuffer = new StringBuilder(); + + while (true) { + String token = AnsiParser.parse(() -> terminal.read(1000)); + if (token == null) { + break; // EOF + } else if (token.isEmpty()) { + continue; // timeout + } else if (token.equals("\u0003")) { // Ctrl+C + break; + } else if (token.equals(PASTE_START)) { + inPaste = true; + pasteBuffer.setLength(0); + System.out.println("[PASTE START]"); + } else if (token.equals(PASTE_END)) { + inPaste = false; + System.out.print("\r\n[PASTE END]\r\n"); + System.out.print("Pasted content (" + pasteBuffer.length() + " chars):\r\n"); + System.out.print("---\r\n"); + // Print with proper line endings for raw mode + String content = pasteBuffer.toString(); + for (int i = 0; i < content.length(); i++) { + char ch = content.charAt(i); + if (ch == '\n') { + System.out.print("\r\n"); + } else if (ch == '\r') { + // Skip standalone CR, will be handled with LF + if (i + 1 < content.length() && content.charAt(i + 1) != '\n') { + System.out.print("\r\n"); + } + } else { + System.out.print(ch); + } + } + System.out.print("\r\n---\r\n\r\n"); + } else if (inPaste) { + pasteBuffer.append(token); + } else { + // Regular typed input + if (token.length() == 1) { + char ch = token.charAt(0); + if (ch >= 32 && ch < 127) { + System.out.println("Typed: '" + ch + "' (code: " + (int) ch + ")"); + } else { + System.out.println("Typed: code " + (int) ch); + } + } else { + System.out.println("Sequence: " + escapeString(token)); + } + } + } + + terminal.write(DISABLE_BRACKETED_PASTE); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static String escapeString(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + if (ch == '\u001b') { + sb.append("\\e"); + } else if (ch >= 32 && ch < 127) { + sb.append(ch); + } else { + sb.append(String.format("\\x%02x", (int) ch)); + } + } + return sb.toString(); + } +} diff --git a/termcap/src/main/java/org/codejive/miniterm/termcap/TermCaps.java b/termcap/src/main/java/org/codejive/miniterm/termcap/TermCaps.java index cc628b8..e36d22b 100644 --- a/termcap/src/main/java/org/codejive/miniterm/termcap/TermCaps.java +++ b/termcap/src/main/java/org/codejive/miniterm/termcap/TermCaps.java @@ -81,6 +81,12 @@ public final class TermCaps { /** Whether the terminal supports overline text. */ private final boolean overline; + /** Whether the terminal supports undercurl (curly underline) text decoration. */ + private final boolean undercurl; + + /** Whether the terminal supports the extended underline specification (styles and colors). */ + private final boolean extendedUnderline; + /** Whether the terminal supports sixel bitmap graphics (DCS sequences). */ private final boolean sixel; @@ -90,6 +96,9 @@ public final class TermCaps { /** Whether the terminal supports the iTerm2 inline image protocol (OSC 1337). */ private final boolean iterm2Images; + /** Whether the terminal supports the Kitty keyboard protocol (progressive enhancement). */ + private final boolean kittyKeyboard; + private TermCaps(Builder b) { this.colors = b.colors; this.altScreen = b.altScreen; @@ -103,9 +112,12 @@ private TermCaps(Builder b) { this.italic = b.italic; this.strikethrough = b.strikethrough; this.overline = b.overline; + this.undercurl = b.undercurl; + this.extendedUnderline = b.extendedUnderline; this.sixel = b.sixel; this.kittyGraphics = b.kittyGraphics; this.iterm2Images = b.iterm2Images; + this.kittyKeyboard = b.kittyKeyboard; } // ── Capability getters ──────────────────────────────────────────────── @@ -170,6 +182,16 @@ public boolean overline() { return overline; } + /** Whether the terminal supports undercurl (curly underline) text decoration. */ + public boolean undercurl() { + return undercurl; + } + + /** Whether the terminal supports the extended underline specification (styles and colors). */ + public boolean extendedUnderline() { + return extendedUnderline; + } + /** Whether the terminal supports sixel bitmap graphics (DCS sequences). */ public boolean sixel() { return sixel; @@ -185,6 +207,11 @@ public boolean iterm2Images() { return iterm2Images; } + /** Whether the terminal supports the Kitty keyboard protocol (progressive enhancement). */ + public boolean kittyKeyboard() { + return kittyKeyboard; + } + // ── Terminal control helpers ────────────────────────────────────────── /** @@ -367,6 +394,245 @@ public static void closeHyperlink(Appendable out) throws java.io.IOException { out.append(OSC).append("8;;").append(OSC_ST); } + /** + * Pushes the Kitty keyboard protocol flags onto the stack and activates progressive + * enhancement. + * + *

The Kitty keyboard protocol provides enhanced keyboard input with support for: + * + *

    + *
  • Disambiguating key events (press vs release, repeat) + *
  • Reporting all modifier keys (Shift, Ctrl, Alt, Super) + *
  • Encoding Unicode characters directly + *
  • Reporting alternate key codes + *
+ * + *

Flags (bitwise OR): + * + *

    + *
  • 1 — Disambiguate escape codes + *
  • 2 — Report event types (press, release, repeat) + *
  • 4 — Report alternate keys + *
  • 8 — Report all keys as escape codes + *
  • 16 — Report associated text + *
+ * + *

Common flag combinations: + * + *

    + *
  • {@code 1} — Basic enhancement (disambiguate only) + *
  • {@code 3} — Enhanced (disambiguate + event types) + *
  • {@code 7} — Full (disambiguate + event types + alternate keys) + *
+ * + *

Use {@link #popKittyKeyboard(Appendable)} to restore the previous state. + * + * @param out terminal output + * @param flags bitwise OR of protocol flags (typically 1, 3, or 7) + * @throws java.io.IOException if writing fails + * @see Kitty keyboard protocol + */ + public static void pushKittyKeyboard(Appendable out, int flags) throws java.io.IOException { + out.append(CSI).append(">").append(String.valueOf(flags)).append("u"); + } + + /** + * Pops the Kitty keyboard protocol flags from the stack, restoring the previous state. + * + *

This should be called before program exit to restore the terminal's original keyboard + * handling mode. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void popKittyKeyboard(Appendable out) throws java.io.IOException { + out.append(CSI).append("The terminal will respond with {@code CSI ? u} if supported, where {@code flags} + * indicates the currently active protocol level. If the terminal does not support the protocol, + * no response is sent. + * + *

This query should be sent in raw mode, and the response read with a timeout (typically + * 100-500ms). + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void queryKittyKeyboard(Appendable out) throws java.io.IOException { + out.append(CSI).append("?u"); + } + + /** + * Returns {@code true} if {@code sequence} is a Kitty keyboard protocol query response. + * + *

The expected form is {@code CSI ? u}. + * + * @param sequence input sequence to test + * @return {@code true} when the sequence is a Kitty keyboard protocol response + */ + public static boolean isKittyKeyboardResponse(String sequence) { + return sequence != null && sequence.startsWith(CSI + "?") && sequence.endsWith("u"); + } + + /** + * Enables undercurl (curly underline) text decoration. + * + *

Undercurl renders text with a wavy/curly underline, commonly used in editors to indicate + * spelling or grammar errors. This uses the SGR parameter {@code 4:3}. + * + *

Not all terminals support undercurl. Check {@link #undercurl()} before using. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void enableUndercurl(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:3m"); + } + + /** + * Disables undercurl (curly underline) text decoration. + * + *

This resets the underline style to none using SGR parameter {@code 4:0}. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void disableUndercurl(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:0m"); + } + + /** + * Enables single underline text decoration. + * + *

This is the standard underline style using SGR parameter {@code 4:1}. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void enableSingleUnderline(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:1m"); + } + + /** + * Enables double underline text decoration. + * + *

Double underline renders text with two parallel underlines using SGR parameter {@code + * 4:2}. + * + *

Not all terminals support double underline. Check {@link #extendedUnderline()} before + * using. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void enableDoubleUnderline(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:2m"); + } + + /** + * Enables dotted underline text decoration. + * + *

Dotted underline renders text with a dotted line underneath using SGR parameter {@code + * 4:4}. + * + *

Not all terminals support dotted underline. Check {@link #extendedUnderline()} before + * using. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void enableDottedUnderline(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:4m"); + } + + /** + * Enables dashed underline text decoration. + * + *

Dashed underline renders text with a dashed line underneath using SGR parameter {@code + * 4:5}. + * + *

Not all terminals support dashed underline. Check {@link #extendedUnderline()} before + * using. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void enableDashedUnderline(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:5m"); + } + + /** + * Disables all underline text decorations. + * + *

This resets the underline style to none using SGR parameter {@code 4:0}. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void disableUnderline(Appendable out) throws java.io.IOException { + out.append(CSI).append("4:0m"); + } + + /** + * Sets the underline color using RGB values. + * + *

This uses the extended underline specification's color support via SGR parameter {@code + * 58:2:r:g:b}. + * + *

Not all terminals support underline colors. Check {@link #extendedUnderline()} before + * using. + * + * @param out terminal output + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @throws java.io.IOException if writing fails + */ + public static void setUnderlineColor(Appendable out, int r, int g, int b) + throws java.io.IOException { + out.append(CSI) + .append("58:2:") + .append(String.valueOf(r)) + .append(":") + .append(String.valueOf(g)) + .append(":") + .append(String.valueOf(b)) + .append("m"); + } + + /** + * Sets the underline color using a 256-color palette index. + * + *

This uses the extended underline specification's color support via SGR parameter {@code + * 58:5:n}. + * + *

Not all terminals support underline colors. Check {@link #extendedUnderline()} before + * using. + * + * @param out terminal output + * @param index color index (0-255) + * @throws java.io.IOException if writing fails + */ + public static void setUnderlineColor(Appendable out, int index) throws java.io.IOException { + out.append(CSI).append("58:5:").append(String.valueOf(index)).append("m"); + } + + /** + * Resets the underline color to the default. + * + *

This uses SGR parameter {@code 59}. + * + * @param out terminal output + * @throws java.io.IOException if writing fails + */ + public static void resetUnderlineColor(Appendable out) throws java.io.IOException { + out.append(CSI).append("59m"); + } + // ── Detection ───────────────────────────────────────────────────────── /** @@ -528,6 +794,8 @@ static void applyEnvVars(Builder b, EnvSource env) { .italic(true) .strikethrough(true) .overline(true) + .undercurl(true) // Windows Terminal 1.19+ + .extendedUnderline(true) // Windows Terminal 1.19+ .sixel(true); // Windows Terminal 1.22+ (August 2024) return; } @@ -546,6 +814,7 @@ static void applyEnvVars(Builder b, EnvSource env) { // TERM_PROGRAM — self-reported by several terminals String termProgram = env.get("TERM_PROGRAM"); + String termProgramVersion = env.get("TERM_PROGRAM_VERSION"); if (termProgram != null) { switch (termProgram) { case "iTerm.app": @@ -560,9 +829,23 @@ static void applyEnvVars(Builder b, EnvSource env) { .italic(true) .strikethrough(true) .overline(true) - .sixel(true) // iTerm2 3.3.0+ - .kittyGraphics(true) // iTerm2 added Kitty graphics in 2024 .iterm2Images(true); + // iTerm2 3.3.0+ supports sixel + if (compareVersion(termProgramVersion, "3.3.0") >= 0) { + b.sixel(true); + } + // iTerm2 3.5.0+ supports Kitty graphics protocol + if (compareVersion(termProgramVersion, "3.5.0") >= 0) { + b.kittyGraphics(true); + } + // iTerm2 3.5.0+ supports Kitty keyboard protocol + if (compareVersion(termProgramVersion, "3.5.0") >= 0) { + b.kittyKeyboard(true); + } + // iTerm2 3.4.0+ supports undercurl and extended underline + if (compareVersion(termProgramVersion, "3.4.0") >= 0) { + b.undercurl(true).extendedUnderline(true); + } break; case "WezTerm": b.colors(16_777_216) @@ -579,7 +862,13 @@ static void applyEnvVars(Builder b, EnvSource env) { .overline(true) .sixel(true) .kittyGraphics(true) - .iterm2Images(true); + .iterm2Images(true) + .undercurl(true) + .extendedUnderline(true); + // WezTerm 20220101+ supports Kitty keyboard protocol + if (compareVersion(termProgramVersion, "20220101") >= 0) { + b.kittyKeyboard(true); + } break; case "kitty": b.colors(16_777_216) @@ -593,11 +882,21 @@ static void applyEnvVars(Builder b, EnvSource env) { .italic(true) .strikethrough(true) .overline(true) - .kittyGraphics(true); // kitty does not support sixel by design + .kittyGraphics(true) // kitty does not support sixel by design + .undercurl(true) + .extendedUnderline(true); + // Kitty 0.20.0+ supports Kitty keyboard protocol (it invented it) + if (compareVersion(termProgramVersion, "0.20.0") >= 0) { + b.kittyKeyboard(true); + } break; case "Apple_Terminal": if (b.colors < 256) b.colors(256); b.settableTitle(true).unicode(true); + // Apple Terminal 2.10+ (macOS 10.15+) supports undercurl + if (compareVersion(termProgramVersion, "2.10") >= 0) { + b.undercurl(true); + } break; case "Ghostty": case "ghostty": @@ -613,7 +912,12 @@ static void applyEnvVars(Builder b, EnvSource env) { .italic(true) .strikethrough(true) .overline(true) - .kittyGraphics(true); + .kittyGraphics(true) + .undercurl(true); + // Ghostty 1.0.0+ supports Kitty keyboard protocol + if (compareVersion(termProgramVersion, "1.0.0") >= 0) { + b.kittyKeyboard(true); + } break; default: break; @@ -642,12 +946,75 @@ static void applyEnvVars(Builder b, EnvSource env) { // assume a VT-capable console host (Win10 1511+) with conservative caps. String os = System.getProperty("os.name", "").toLowerCase(); if (os.contains("windows") && b.colors == 0) { - b.colors(8).altScreen(true).mouse(true).unicode(true); + b.colors(256).altScreen(true).mouse(true).unicode(true); } } // ── Layer 3: terminfo binary ────────────────────────────────────────── + // ── Version comparison helper ───────────────────────────────────────── + + /** + * Compares two version strings in the format "major.minor.patch" or "YYYYMMDD". + * + *

Returns: + * + *

    + *
  • negative if {@code version} is less than {@code reference} + *
  • zero if {@code version} equals {@code reference} + *
  • positive if {@code version} is greater than {@code reference} + *
  • -1 if {@code version} is null or cannot be parsed + *
+ * + *

Supports: + * + *

    + *
  • Semantic versioning: "1.2.3", "0.20.0" + *
  • Date-based versioning: "20220101" (WezTerm style) + *
  • Two-part versions: "2.10" (Apple Terminal style) + *
+ * + * @param version the version string to compare (may be null) + * @param reference the reference version string + * @return comparison result + */ + private static int compareVersion(String version, String reference) { + if (version == null || version.isEmpty()) return -1; + if (reference == null || reference.isEmpty()) return 1; + + // Try numeric comparison first (for date-based versions like "20220101") + try { + long v = Long.parseLong(version); + long r = Long.parseLong(reference); + return Long.compare(v, r); + } catch (NumberFormatException e) { + // Not purely numeric, fall through to component-wise comparison + } + + // Split on dots and compare component-wise + String[] vParts = version.split("\\."); + String[] rParts = reference.split("\\."); + int maxLen = Math.max(vParts.length, rParts.length); + + for (int i = 0; i < maxLen; i++) { + int vNum = i < vParts.length ? parseVersionComponent(vParts[i]) : 0; + int rNum = i < rParts.length ? parseVersionComponent(rParts[i]) : 0; + if (vNum != rNum) { + return Integer.compare(vNum, rNum); + } + } + return 0; + } + + private static int parseVersionComponent(String component) { + if (component == null || component.isEmpty()) return 0; + try { + return Integer.parseInt(component); + } catch (NumberFormatException e) { + return 0; + } + } + static void applyTerminfo(Builder b, String term) { if (term == null || term.isEmpty()) return; String os = System.getProperty("os.name", "").toLowerCase(); @@ -675,9 +1042,12 @@ public static final class Builder { private boolean italic; private boolean strikethrough; private boolean overline; + private boolean undercurl; + private boolean extendedUnderline; private boolean sixel; private boolean kittyGraphics; private boolean iterm2Images; + private boolean kittyKeyboard; private Builder() {} @@ -694,9 +1064,12 @@ private Builder(TermCaps base) { this.italic = base.italic; this.strikethrough = base.strikethrough; this.overline = base.overline; + this.undercurl = base.undercurl; + this.extendedUnderline = base.extendedUnderline; this.sixel = base.sixel; this.kittyGraphics = base.kittyGraphics; this.iterm2Images = base.iterm2Images; + this.kittyKeyboard = base.kittyKeyboard; } /** Returns the currently set colors value. */ @@ -764,6 +1137,16 @@ public Builder overline(boolean v) { return this; } + public Builder undercurl(boolean v) { + this.undercurl = v; + return this; + } + + public Builder extendedUnderline(boolean v) { + this.extendedUnderline = v; + return this; + } + public Builder sixel(boolean v) { this.sixel = v; return this; @@ -779,6 +1162,11 @@ public Builder iterm2Images(boolean v) { return this; } + public Builder kittyKeyboard(boolean v) { + this.kittyKeyboard = v; + return this; + } + public TermCaps build() { return new TermCaps(this); } diff --git a/termcap/src/main/java/org/codejive/miniterm/termcap/TermProber.java b/termcap/src/main/java/org/codejive/miniterm/termcap/TermProber.java index 12719ed..39a3023 100644 --- a/termcap/src/main/java/org/codejive/miniterm/termcap/TermProber.java +++ b/termcap/src/main/java/org/codejive/miniterm/termcap/TermProber.java @@ -241,6 +241,20 @@ private static void applyDa2(TermCaps.Builder b, int type, int version) { } } + /** + * Applies a Kitty keyboard protocol query response to the given {@link TermCaps.Builder}. + * + * @param seq the raw Kitty keyboard protocol response sequence + * @param b the builder to update + * @return {@code true} if {@code seq} was a valid response and the builder was updated + */ + public static boolean applyKittyKeyboard(String seq, TermCaps.Builder b) { + if (!TermCaps.isKittyKeyboardResponse(seq)) return false; + // If we got a response, the terminal supports the Kitty keyboard protocol + b.kittyKeyboard(true); + return true; + } + // ── Response reading ────────────────────────────────────────────────── /** diff --git a/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTest.java b/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTest.java index a81e2ac..3271ea3 100644 --- a/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTest.java +++ b/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTest.java @@ -1,6 +1,9 @@ package org.codejive.miniterm.termcap; import static org.assertj.core.api.Assertions.assertThat; +import static org.codejive.miniterm.termcap.TermCapsTestHelper.detectTerm; +import static org.codejive.miniterm.termcap.TermCapsTestHelper.detectWithEnv; +import static org.codejive.miniterm.termcap.TermCapsTestHelper.detectWithEnvAndVersion; import org.junit.jupiter.api.Test; @@ -23,6 +26,8 @@ void builderDefaultsAreAllOff() { assertThat(caps.italic()).isFalse(); assertThat(caps.strikethrough()).isFalse(); assertThat(caps.overline()).isFalse(); + assertThat(caps.kittyKeyboard()).isFalse(); + assertThat(caps.undercurl()).isFalse(); } @Test @@ -41,6 +46,8 @@ void builderSetsEachCapability() { .italic(true) .strikethrough(true) .overline(true) + .kittyKeyboard(true) + .undercurl(true) .build(); assertThat(caps.colors()).isEqualTo(256); assertThat(caps.altScreen()).isTrue(); @@ -54,6 +61,8 @@ void builderSetsEachCapability() { assertThat(caps.italic()).isTrue(); assertThat(caps.strikethrough()).isTrue(); assertThat(caps.overline()).isTrue(); + assertThat(caps.kittyKeyboard()).isTrue(); + assertThat(caps.undercurl()).isTrue(); } @Test @@ -76,29 +85,12 @@ void builderFromBaseAllowsOverride() { } // ── Layer 1: TERM env var ───────────────────────────────────────────────── - // We test applyTerm via the package-visible detect(term) overload that is - // absent from the public API, so we call the internal helper indirectly - // through a minimal builder-wrapper. @Test void termDumbGivesNoCapabilities() { TermCaps caps = detectTerm("dumb"); assertThat(caps.colors()).isZero(); assertThat(caps.altScreen()).isFalse(); - assertThat(caps.mouse()).isFalse(); - } - - @Test - void termVt100GivesNoCapabilities() { - TermCaps caps = detectTerm("vt100"); - assertThat(caps.colors()).isZero(); - } - - @Test - void termAnsiGives8Colors() { - TermCaps caps = detectTerm("ansi"); - assertThat(caps.colors()).isEqualTo(8); - assertThat(caps.altScreen()).isFalse(); } @Test @@ -198,40 +190,78 @@ void termProgramKittyGivesFullCaps() { assertThat(caps.focusTracking()).isTrue(); } + // ── New capabilities: kittyKeyboard and undercurl ───────────────────────── + @Test - void termProgramAppleTerminalGives256Colors() { - TermCaps caps = detectWithEnv("xterm-256color", null, "Apple_Terminal", null, null); - assertThat(caps.colors()).isGreaterThanOrEqualTo(256); - assertThat(caps.settableTitle()).isTrue(); + void builderSetsKittyKeyboard() { + TermCaps caps = TermCaps.builder().kittyKeyboard(true).build(); + assertThat(caps.kittyKeyboard()).isTrue(); } @Test - void vteVersionGivesAtLeast256Colors() { - TermCaps caps = detectWithEnv("xterm", null, null, "5202", null); - assertThat(caps.colors()).isGreaterThanOrEqualTo(256); - assertThat(caps.bracketedPaste()).isTrue(); - assertThat(caps.hyperlinks()).isTrue(); + void builderSetsUndercurl() { + TermCaps caps = TermCaps.builder().undercurl(true).build(); + assertThat(caps.undercurl()).isTrue(); } @Test - void tmuxEnvVarEnablesMouse() { - // Inside tmux, TERM=screen so mouse is off by default; TMUX env should enable it - TermCaps caps = detectWithEnv("screen", null, null, null, "set-of-values"); - assertThat(caps.mouse()).isTrue(); + void termProgramITermWithVersionSupportsKittyKeyboard() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "iTerm.app", "3.5.0", null, null); + assertThat(caps.kittyKeyboard()).isTrue(); } - // ── helpers ─────────────────────────────────────────────────────────────── + @Test + void termProgramITermOldVersionDoesNotSupportKittyKeyboard() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "iTerm.app", "3.4.0", null, null); + assertThat(caps.kittyKeyboard()).isFalse(); + } - /** - * Applies only Layer 1 (TERM) by directly driving the internal builder logic. We do this via - * the package-visible {@code TermCapsTestHelper}, which calls the same private static methods. - */ - private static TermCaps detectTerm(String term) { - return TermCapsTestHelper.detectTerm(term); + @Test + void termProgramITermWithVersionSupportsUndercurl() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "iTerm.app", "3.4.0", null, null); + assertThat(caps.undercurl()).isTrue(); + } + + @Test + void termProgramKittyWithVersionSupportsKittyKeyboard() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "kitty", "0.20.0", null, null); + assertThat(caps.kittyKeyboard()).isTrue(); + assertThat(caps.undercurl()).isTrue(); } - private static TermCaps detectWithEnv( - String term, String colorterm, String termProgram, String vteVersion, String tmux) { - return TermCapsTestHelper.detectWithEnv(term, colorterm, termProgram, vteVersion, tmux); + @Test + void termProgramWezTermWithVersionSupportsKittyKeyboard() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "WezTerm", "20220101", null, null); + assertThat(caps.kittyKeyboard()).isTrue(); + assertThat(caps.undercurl()).isTrue(); + } + + @Test + void termProgramGhosttyWithVersionSupportsKittyKeyboard() { + TermCaps caps = + detectWithEnvAndVersion("xterm-256color", null, "Ghostty", "1.0.0", null, null); + assertThat(caps.kittyKeyboard()).isTrue(); + assertThat(caps.undercurl()).isTrue(); + } + + @Test + void termProgramAppleTerminalWithVersionSupportsUndercurl() { + TermCaps caps = + detectWithEnvAndVersion( + "xterm-256color", null, "Apple_Terminal", "2.10", null, null); + assertThat(caps.undercurl()).isTrue(); + } + + @Test + void termProgramAppleTerminalOldVersionDoesNotSupportUndercurl() { + TermCaps caps = + detectWithEnvAndVersion( + "xterm-256color", null, "Apple_Terminal", "2.9", null, null); + assertThat(caps.undercurl()).isFalse(); } } diff --git a/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTestHelper.java b/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTestHelper.java index 1c78fae..0c705b1 100644 --- a/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTestHelper.java +++ b/termcap/src/test/java/org/codejive/miniterm/termcap/TermCapsTestHelper.java @@ -29,9 +29,30 @@ static TermCaps detectTerm(String term) { */ static TermCaps detectWithEnv( String term, String colorterm, String termProgram, String vteVersion, String tmux) { + return detectWithEnvAndVersion(term, colorterm, termProgram, null, vteVersion, tmux); + } + + /** + * Apply Layer 1 + Layer 2 with injected environment variable values including version. + * + * @param term value of {@code $TERM} + * @param colorterm value of {@code $COLORTERM} (may be null) + * @param termProgram value of {@code $TERM_PROGRAM} (may be null) + * @param termProgramVersion value of {@code $TERM_PROGRAM_VERSION} (may be null) + * @param vteVersion value of {@code $VTE_VERSION} (may be null) + * @param tmux value of {@code $TMUX} (may be null; any non-null value means "inside tmux") + */ + static TermCaps detectWithEnvAndVersion( + String term, + String colorterm, + String termProgram, + String termProgramVersion, + String vteVersion, + String tmux) { Map env = new HashMap(); if (colorterm != null) env.put("COLORTERM", colorterm); if (termProgram != null) env.put("TERM_PROGRAM", termProgram); + if (termProgramVersion != null) env.put("TERM_PROGRAM_VERSION", termProgramVersion); if (vteVersion != null) env.put("VTE_VERSION", vteVersion); if (tmux != null) env.put("TMUX", tmux);