package bjc.utils.gui; import javax.swing.*; import javax.swing.event.ChangeListener; import javax.swing.text.*; import java.awt.*; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.io.*; import java.util.Arrays; /** * JSON syntax highlighting for JTextPane via StyledEditorKit/StyledDocument. * * Includes: * 1) Highlights only the visible region (plus margin) for performance. * 2) Rainbow brace/bracket coloring: each matching pair shares a color by nesting depth. * 3) Caret pair emphasis: when caret is on/after a brace, emphasize it + its matching pair. * 4) Underlines mismatched/unpaired braces/brackets. * * Usage: * JTextPane pane = new JTextPane(); * pane.setEditorKit(new JsonEditorKit()); * JsonEditorKit.JsonHighlightSupport.install(pane); * ((JsonEditorKit.JsonDocument)pane.getDocument()).setJson(jsonString); */ public class JsonEditorKit extends StyledEditorKit { private static final long serialVersionUID = 7378968732241012969L; @Override public String getContentType() { return "application/json"; } @Override public Document createDefaultDocument() { return new JsonDocument(); } /** * Set the JSON contents of this document and a text pane * * @param pane The text pane * @param json The JSON document */ public void setJson(JTextPane pane, String json) { if (!(pane.getDocument() instanceof JsonDocument)) { pane.setDocument(createDefaultDocument()); } ((JsonDocument) pane.getDocument()).setJson(json); } @Override public void read(Reader in, Document doc, int pos) throws IOException, BadLocationException { String json = readAll(in); if (doc instanceof JsonDocument) { ((JsonDocument) doc).insertJsonAt(pos, json); } else if (doc instanceof StyledDocument) { StyledDocument sd = (StyledDocument) doc; sd.insertString(pos, json, null); JsonHighlighter.highlight(sd, 0, sd.getLength(), JsonStyles.defaults(), null, -1); } else { super.read(new StringReader(json), doc, pos); } } @Override public void write(Writer out, Document doc, int pos, int len) throws IOException, BadLocationException { out.write(doc.getText(pos, len)); } private static String readAll(Reader r) throws IOException { StringBuilder sb = new StringBuilder(4096); char[] buf = new char[4096]; int n; while ((n = r.read(buf)) >= 0) sb.append(buf, 0, n); return sb.toString(); } // ------------------------------------------------------------------------- // Install helper: visible-region updates + caret updates // ------------------------------------------------------------------------- /** * Helper for doing JSON highlighting */ public static final class JsonHighlightSupport { private static final String INSTALLED_KEY = "json.highlighter.installed"; private static final int DEFAULT_MARGIN_CHARS = 2048; private JsonHighlightSupport() { } /** * Install the JSON highlighter into a pane * * @param pane The pane to install the highlighter into */ public static void install(JTextPane pane) { if (pane == null) throw new IllegalArgumentException("pane == null"); if (Boolean.TRUE.equals(pane.getClientProperty(INSTALLED_KEY))) return; pane.putClientProperty(INSTALLED_KEY, Boolean.TRUE); if (!(pane.getDocument() instanceof JsonDocument)) { pane.setEditorKit(new JsonEditorKit()); } final JsonDocument doc = (JsonDocument) pane.getDocument(); doc.attachView(pane); doc.setVisibleHighlightMargin(DEFAULT_MARGIN_CHARS); // Re-highlight on resize pane.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { doc.queueHighlight(); } }); // Re-highlight on caret moves (pair emphasis) pane.addCaretListener(e -> doc.queueHighlight()); // Re-highlight on viewport scrolling final Runnable hookViewport = () -> { JViewport vp = (JViewport) SwingUtilities.getAncestorOfClass(JViewport.class, pane); if (vp == null) return; final String VP_LISTENER_KEY = "json.highlighter.viewport.listener"; if (vp.getClientProperty(VP_LISTENER_KEY) != null) return; ChangeListener cl = e -> doc.queueHighlight(); vp.addChangeListener(cl); vp.putClientProperty(VP_LISTENER_KEY, cl); }; pane.addHierarchyListener(e -> hookViewport.run()); hookViewport.run(); doc.queueHighlight(); } } // ------------------------------------------------------------------------- // Document // ------------------------------------------------------------------------- public static class JsonDocument extends DefaultStyledDocument { private final JsonStyles styles = JsonStyles.defaults(); private transient JTextComponent view; private boolean highlightQueued = false; private int visibleMarginChars = 2048; // Recompute rainbow pairing only when content changes private boolean structureDirty = true; private JsonHighlighter.RainbowData rainbowData = null; public JsonDocument() { super(); addDocumentListener(new javax.swing.event.DocumentListener() { @Override public void insertUpdate(javax.swing.event.DocumentEvent e) { onContentChanged(); } @Override public void removeUpdate(javax.swing.event.DocumentEvent e) { onContentChanged(); } @Override public void changedUpdate(javax.swing.event.DocumentEvent e) { /* ignore attribute changes */ } }); } public void attachView(JTextComponent view) { this.view = view; } public void setVisibleHighlightMargin(int chars) { this.visibleMarginChars = Math.max(0, chars); } public void setJson(String json) { try { remove(0, getLength()); insertString(0, json, null); } catch (BadLocationException e) { throw new RuntimeException(e); } } public void insertJsonAt(int pos, String json) throws BadLocationException { insertString(pos, json, null); } private void onContentChanged() { structureDirty = true; queueHighlight(); } void queueHighlight() { if (highlightQueued) return; highlightQueued = true; SwingUtilities.invokeLater(() -> { highlightQueued = false; try { doHighlight(); } catch (BadLocationException ignored) { } }); } private void doHighlight() throws BadLocationException { int docLen = getLength(); if (docLen <= 0) return; // Recompute rainbow mapping when content changed if (structureDirty) { String fullText = getText(0, docLen); rainbowData = JsonHighlighter.computeRainbow(fullText, styles.rainbowOverlays.length); structureDirty = false; } int start = 0; int end = docLen; int caretPos = -1; if (view != null && view.isShowing()) { caretPos = view.getCaretPosition(); int[] range = computeVisibleRange(view, docLen, visibleMarginChars); start = range[0]; end = range[1]; } int len = Math.max(0, end - start); if (len <= 0) return; // Determine whether caret is on/after a brace/bracket int activeBraceOffset = -1; if (caretPos >= 0 && rainbowData != null && rainbowData.offsets.length > 0) { activeBraceOffset = findBraceAtCaret(this, caretPos, rainbowData); } JsonHighlighter.highlight(this, start, len, styles, rainbowData, activeBraceOffset); } private static int findBraceAtCaret(StyledDocument doc, int caretPos, JsonHighlighter.RainbowData rd) { try { int docLen = doc.getLength(); // Prefer caret-1 (common editor behavior: caret after brace highlights it) int[] candidates = new int[] { caretPos - 1, caretPos }; for (int off : candidates) { if (off < 0 || off >= docLen) continue; char ch = doc.getText(off, 1).charAt(0); if (!(ch == '{' || ch == '}' || ch == '[' || ch == ']')) continue; // Only accept if this brace is actually part of the parsed structure // (i.e., not in string/comment), which is represented by rd.offsets. int idx = JsonHighlighter.lowerBound(rd.offsets, off); if (idx < rd.offsets.length && rd.offsets[idx] == off) return off; } } catch (BadLocationException ignored) { } return -1; } private static int[] computeVisibleRange(JTextComponent comp, int docLen, int marginChars) { Rectangle r = comp.getVisibleRect(); Point p1 = new Point(r.x, r.y); Point p2 = new Point(r.x + r.width, r.y + r.height); int a = safeViewToModel(comp, p1); int b = safeViewToModel(comp, p2); int start = Math.max(0, Math.min(a, b)); int end = Math.min(docLen, Math.max(a, b)); start = Math.max(0, start - marginChars); end = Math.min(docLen, end + marginChars); if (end < start) { int t = start; start = end; end = t; } return new int[] { start, end }; } @SuppressWarnings("deprecation") // Java 8 compatible private static int safeViewToModel(JTextComponent comp, Point p) { try { return Math.max(0, comp.viewToModel(p)); } catch (Exception e) { return 0; } } } // ------------------------------------------------------------------------- // Styles // ------------------------------------------------------------------------- public static final class JsonStyles { public final SimpleAttributeSet base = new SimpleAttributeSet(); public final SimpleAttributeSet key; public final SimpleAttributeSet string; public final SimpleAttributeSet number; public final SimpleAttributeSet boolNull; public final SimpleAttributeSet punctuation; public final SimpleAttributeSet comment; /** Rainbow overlays for braces/brackets (apply with replace=false). */ public final SimpleAttributeSet[] rainbowOverlays; /** For mismatches/unpaired (apply with replace=false). */ public final SimpleAttributeSet mismatchOverlay; /** Caret brace emphasis (stronger). */ public final SimpleAttributeSet caretBraceOverlay; /** Matching brace emphasis (weaker). */ public final SimpleAttributeSet matchBraceOverlay; private JsonStyles(SimpleAttributeSet key, SimpleAttributeSet string, SimpleAttributeSet number, SimpleAttributeSet boolNull, SimpleAttributeSet punctuation, SimpleAttributeSet comment, SimpleAttributeSet[] rainbowOverlays, SimpleAttributeSet mismatchOverlay, SimpleAttributeSet caretBraceOverlay, SimpleAttributeSet matchBraceOverlay) { this.key = key; this.string = string; this.number = number; this.boolNull = boolNull; this.punctuation = punctuation; this.comment = comment; this.rainbowOverlays = rainbowOverlays; this.mismatchOverlay = mismatchOverlay; this.caretBraceOverlay = caretBraceOverlay; this.matchBraceOverlay = matchBraceOverlay; } public static JsonStyles defaults() { // Base SimpleAttributeSet base = new SimpleAttributeSet(); StyleConstants.setFontFamily(base, "Monospaced"); StyleConstants.setFontSize(base, 13); SimpleAttributeSet key = cloneWith(base); StyleConstants.setForeground(key, new Color(0, 92, 197)); StyleConstants.setBold(key, true); SimpleAttributeSet string = cloneWith(base); StyleConstants.setForeground(string, new Color(34, 139, 34)); SimpleAttributeSet number = cloneWith(base); StyleConstants.setForeground(number, new Color(163, 73, 164)); SimpleAttributeSet boolNull = cloneWith(base); StyleConstants.setForeground(boolNull, new Color(184, 92, 0)); StyleConstants.setBold(boolNull, true); SimpleAttributeSet punctuation = cloneWith(base); StyleConstants.setForeground(punctuation, new Color(120, 120, 120)); SimpleAttributeSet comment = cloneWith(base); StyleConstants.setForeground(comment, new Color(140, 140, 140)); StyleConstants.setItalic(comment, true); // Rainbow palette (light background friendly) Color[] palette = new Color[] { new Color(216, 27, 96), // pink/red new Color(255, 143, 0), // orange new Color(251, 192, 45), // amber new Color(67, 160, 71), // green new Color(0, 172, 193), // cyan new Color(30, 136, 229), // blue new Color(94, 53, 177), // deep purple new Color(142, 36, 170) // violet }; SimpleAttributeSet[] rainbow = new SimpleAttributeSet[palette.length]; for (int i = 0; i < palette.length; i++) { SimpleAttributeSet o = new SimpleAttributeSet(); StyleConstants.setForeground(o, palette[i]); StyleConstants.setBold(o, true); rainbow[i] = o; } // Mismatch: underline red + red foreground SimpleAttributeSet mismatch = new SimpleAttributeSet(); StyleConstants.setUnderline(mismatch, true); StyleConstants.setForeground(mismatch, new Color(200, 0, 0)); StyleConstants.setBold(mismatch, true); // Caret brace emphasis (strong): background + underline SimpleAttributeSet caretEm = new SimpleAttributeSet(); StyleConstants.setBackground(caretEm, new Color(255, 242, 179)); // slightly stronger yellow StyleConstants.setUnderline(caretEm, true); StyleConstants.setBold(caretEm, true); // Matching brace emphasis (soft): background only SimpleAttributeSet matchEm = new SimpleAttributeSet(); StyleConstants.setBackground(matchEm, new Color(255, 250, 210)); // softer yellow StyleConstants.setBold(matchEm, true); JsonStyles s = new JsonStyles(key, string, number, boolNull, punctuation, comment, rainbow, mismatch, caretEm, matchEm); s.base.addAttributes(base); return s; } private static SimpleAttributeSet cloneWith(SimpleAttributeSet base) { SimpleAttributeSet a = new SimpleAttributeSet(); a.addAttributes(base); return a; } } // ------------------------------------------------------------------------- // Highlighter / Lexer + Rainbow Pairing + Caret emphasis // ------------------------------------------------------------------------- static final class JsonHighlighter { /** * Rainbow mapping: - offsets: positions of braces/brackets to color (sorted) - * colorIndex: palette index for that brace/bracket - match: matching brace * offset (or -1 if unpaired) - mismatches: offsets of unpaired/mismatched * braces/brackets (sorted) */ static final class RainbowData { final int[] offsets; final byte[] colorIndex; final int[] match; final int[] mismatches; RainbowData(int[] offsets, byte[] colorIndex, int[] match, int[] mismatches) { this.offsets = offsets; this.colorIndex = colorIndex; this.match = match; this.mismatches = mismatches; } } static void highlight(StyledDocument doc, int start, int length, JsonStyles styles, RainbowData rainbowData, int activeBraceOffset) throws BadLocationException { if (length <= 0) return; String text = doc.getText(start, length); // Reset region doc.setCharacterAttributes(start, length, styles.base, true); int i = 0; final int n = text.length(); while (i < n) { char c = text.charAt(i); if (isWs(c)) { i++; continue; } // punctuation if (isPunct(c)) { apply(doc, start + i, 1, styles.punctuation, true); i++; continue; } // comments (JSONC-ish): //... or /*...*/ if (c == '/' && i + 1 < n) { char d = text.charAt(i + 1); if (d == '/') { int j = i + 2; while (j < n && text.charAt(j) != '\n') j++; apply(doc, start + i, j - i, styles.comment, true); i = j; continue; } if (d == '*') { int j = i + 2; boolean closed = false; while (j + 1 < n) { if (text.charAt(j) == '*' && text.charAt(j + 1) == '/') { j += 2; closed = true; break; } j++; } if (closed) { apply(doc, start + i, j - i, styles.comment, true); i = j; continue; } else { apply(doc, start + i, n - i, styles.mismatchOverlay, false); break; } } } // string if (c == '"') { int j = i + 1; boolean escaped = false; boolean closed = false; while (j < n) { char ch = text.charAt(j); if (escaped) { escaped = false; } else if (ch == '\\') { escaped = true; } else if (ch == '"') { j++; closed = true; break; } j++; } if (!closed) { apply(doc, start + i, n - i, styles.mismatchOverlay, false); break; } // Key detection: string followed by ':' after whitespace int k = j; while (k < n && isWs(text.charAt(k))) k++; boolean isKey = (k < n && text.charAt(k) == ':'); apply(doc, start + i, j - i, isKey ? styles.key : styles.string, true); i = j; continue; } // keywords: true false null if (isAlpha(c)) { int j = i + 1; while (j < n && isAlpha(text.charAt(j))) j++; String word = text.substring(i, j); if ("true".equals(word) || "false".equals(word) || "null".equals(word)) { apply(doc, start + i, j - i, styles.boolNull, true); } i = j; continue; } // number if (c == '-' || isDigit(c)) { int j = scanNumber(text, i); if (j > i) { apply(doc, start + i, j - i, styles.number, true); i = j; continue; } } i++; } // Apply rainbow overlays for braces/brackets within region if (rainbowData != null && rainbowData.offsets.length > 0) { int regionStart = start; int regionEnd = start + length; int idx = lowerBound(rainbowData.offsets, regionStart); while (idx < rainbowData.offsets.length) { int off = rainbowData.offsets[idx]; if (off >= regionEnd) break; int paletteIdx = (rainbowData.colorIndex[idx] & 0xFF) % styles.rainbowOverlays.length; apply(doc, off, 1, styles.rainbowOverlays[paletteIdx], false); idx++; } } // Apply mismatch underline on top (will override rainbow to red) if (rainbowData != null && rainbowData.mismatches.length > 0) { int regionStart = start; int regionEnd = start + length; int idx = lowerBound(rainbowData.mismatches, regionStart); while (idx < rainbowData.mismatches.length) { int off = rainbowData.mismatches[idx]; if (off >= regionEnd) break; apply(doc, off, 1, styles.mismatchOverlay, false); idx++; } } // Caret pair emphasis (on top of rainbow; mismatch still wins visually if // present) if (rainbowData != null && activeBraceOffset >= 0) { int idx = lowerBound(rainbowData.offsets, activeBraceOffset); if (idx < rainbowData.offsets.length && rainbowData.offsets[idx] == activeBraceOffset) { int matchOff = rainbowData.match[idx]; if (matchOff >= 0) { int regionStart = start; int regionEnd = start + length; // Emphasize active brace strongly if (activeBraceOffset >= regionStart && activeBraceOffset < regionEnd) { apply(doc, activeBraceOffset, 1, styles.caretBraceOverlay, false); } // Emphasize matching brace softly if (matchOff >= regionStart && matchOff < regionEnd) { apply(doc, matchOff, 1, styles.matchBraceOverlay, false); } } } } } /** * Compute rainbow coloring for matching {} and [] pairs using nesting depth. * Skips braces/brackets in strings and (optional) JSONC comments. * * Openers get color by current depth; closers get their opener's color. * Unpaired/mismatched braces are recorded in mismatches; openers keep their * rainbow color even if unclosed. */ static RainbowData computeRainbow(String text, int paletteSize) { if (text == null || text.isEmpty()) return new RainbowData(new int[0], new byte[0], new int[0], new int[0]); if (paletteSize <= 0) paletteSize = 1; final int n = text.length(); // mapping arrays (offsets sorted by scan) int[] offsets = new int[Math.min(4096, n)]; byte[] colors = new byte[offsets.length]; int[] match = new int[offsets.length]; int count = 0; // mismatches (cap) int capMismatch = 512; int[] mism = new int[capMismatch]; int mismCount = 0; // stack: store opener type + opener entry index char[] stCh = new char[Math.min(1024, n)]; int[] stEntryIdx = new int[stCh.length]; int[] stPos = new int[stCh.length]; int top = 0; boolean inString = false; boolean escaped = false; boolean inLineComment = false; boolean inBlockComment = false; for (int i = 0; i < n; i++) { char c = text.charAt(i); if (inLineComment) { if (c == '\n') inLineComment = false; continue; } if (inBlockComment) { if (c == '*' && i + 1 < n && text.charAt(i + 1) == '/') { inBlockComment = false; i++; } continue; } if (inString) { if (escaped) { escaped = false; } else if (c == '\\') { escaped = true; } else if (c == '"') { inString = false; } continue; } else if (c == '"') { inString = true; escaped = false; continue; } // JSONC-ish comments if (c == '/' && i + 1 < n) { char d = text.charAt(i + 1); if (d == '/') { inLineComment = true; i++; continue; } if (d == '*') { inBlockComment = true; i++; continue; } } // openers if (c == '{' || c == '[') { byte ci = (byte) (top % paletteSize); // ensure mapping arrays capacity if (count >= offsets.length) { int newLen = Math.min(n, offsets.length * 2); offsets = Arrays.copyOf(offsets, newLen); colors = Arrays.copyOf(colors, newLen); match = Arrays.copyOf(match, newLen); } offsets[count] = i; colors[count] = ci; match[count] = -1; // fill later int entryIdx = count; count++; // push onto stack if (top >= stCh.length) { int newLen = Math.min(n, stCh.length * 2); stCh = Arrays.copyOf(stCh, newLen); stEntryIdx = Arrays.copyOf(stEntryIdx, newLen); stPos = Arrays.copyOf(stPos, newLen); } stCh[top] = c; stEntryIdx[top] = entryIdx; stPos[top] = i; top++; continue; } // closers if (c == '}' || c == ']') { if (top == 0) { if (mismCount < capMismatch) mism[mismCount++] = i; continue; } char open = stCh[top - 1]; int openEntryIdx = stEntryIdx[top - 1]; boolean matchType = (open == '{' && c == '}') || (open == '[' && c == ']'); if (!matchType) { if (mismCount < capMismatch) mism[mismCount++] = i; // recovery: pop one to reduce cascading errors top--; continue; } // matching pair top--; // ensure mapping arrays capacity for closer entry if (count >= offsets.length) { int newLen = Math.min(n, offsets.length * 2); offsets = Arrays.copyOf(offsets, newLen); colors = Arrays.copyOf(colors, newLen); match = Arrays.copyOf(match, newLen); } byte ci = colors[openEntryIdx]; offsets[count] = i; colors[count] = ci; match[count] = offsets[openEntryIdx]; // closer -> opener match[openEntryIdx] = i; // opener -> closer count++; continue; } } // leftover openers are mismatches (their match stays -1) while (top > 0 && mismCount < capMismatch) { top--; mism[mismCount++] = stPos[top]; } int[] outOffsets = Arrays.copyOf(offsets, count); byte[] outColors = Arrays.copyOf(colors, count); int[] outMatch = Arrays.copyOf(match, count); int[] outMism = Arrays.copyOf(mism, mismCount); Arrays.sort(outMism); return new RainbowData(outOffsets, outColors, outMatch, outMism); } // --- helpers --- private static void apply(StyledDocument doc, int offset, int len, AttributeSet style, boolean replace) { doc.setCharacterAttributes(offset, len, style, replace); } private static boolean isWs(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f'; } private static boolean isDigit(char c) { return c >= '0' && c <= '9'; } private static boolean isAlpha(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } private static boolean isPunct(char c) { return c == '{' || c == '}' || c == '[' || c == ']' || c == ':' || c == ','; } static int lowerBound(int[] a, int key) { int lo = 0, hi = a.length; while (lo < hi) { int mid = (lo + hi) >>> 1; if (a[mid] < key) lo = mid + 1; else hi = mid; } return lo; } /** * JSON number: -? (0|[1-9]\d*) (\.\d+)? ([eE][+-]?\d+)? Returns end index * (exclusive). If not a valid prefix, returns i. */ private static int scanNumber(String s, int i) { int n = s.length(); int j = i; if (j < n && s.charAt(j) == '-') j++; int intStart = j; if (j < n && s.charAt(j) == '0') { j++; } else { if (j >= n || !isDigit(s.charAt(j))) return i; while (j < n && isDigit(s.charAt(j))) j++; } // fraction if (j < n && s.charAt(j) == '.') { int dot = j; j++; if (j >= n || !isDigit(s.charAt(j))) return dot; while (j < n && isDigit(s.charAt(j))) j++; } // exponent if (j < n && (s.charAt(j) == 'e' || s.charAt(j) == 'E')) { int epos = j; j++; if (j < n && (s.charAt(j) == '+' || s.charAt(j) == '-')) j++; if (j >= n || !isDigit(s.charAt(j))) return epos; while (j < n && isDigit(s.charAt(j))) j++; } if (j == intStart) return i; return j; } } }