From eef6e132080c5e46ba8c47ecfaca83fa8e0e214e Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Wed, 28 Jan 2026 21:36:12 -0500 Subject: Add various text UI components This adds a variety of text UI components, namely two suites: * One that is geared towards JSON * One that is geared towards Markdown Details to (perhaps) follow later --- .../src/main/java/bjc/utils/gui/JsonEditorKit.java | 899 +++++++++++++++++++ .../main/java/bjc/utils/gui/MarkdownEditorKit.java | 948 +++++++++++++++++++++ .../java/bjc/utils/gui/MarkdownLinkSupport.java | 374 ++++++++ .../java/bjc/utils/gui/MarkdownSplitPreview.java | 457 ++++++++++ .../java/bjc/utils/misc/SmartJSONFormatter.java | 520 +++++++++++ 5 files changed, 3198 insertions(+) create mode 100644 base/src/main/java/bjc/utils/gui/JsonEditorKit.java create mode 100644 base/src/main/java/bjc/utils/gui/MarkdownEditorKit.java create mode 100644 base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java create mode 100644 base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java create mode 100644 base/src/main/java/bjc/utils/misc/SmartJSONFormatter.java (limited to 'base/src/main') diff --git a/base/src/main/java/bjc/utils/gui/JsonEditorKit.java b/base/src/main/java/bjc/utils/gui/JsonEditorKit.java new file mode 100644 index 0000000..46519cf --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/JsonEditorKit.java @@ -0,0 +1,899 @@ +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; + } + } +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/MarkdownEditorKit.java b/base/src/main/java/bjc/utils/gui/MarkdownEditorKit.java new file mode 100644 index 0000000..24965e8 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownEditorKit.java @@ -0,0 +1,948 @@ +package bjc.utils.gui; + +import javax.swing.*; +import javax.swing.text.*; + +import java.awt.*; +import java.io.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Basic Markdown -> StyledDocument renderer for Swing. + * + * Supported: + * - Headings #..### + * - Bold **...** / __...__ + * - Italic *...* / _..._ + * - Inline code `...` + * - Fenced code blocks ``` or ~~~ + * - Blockquotes > + * - Lists (unordered and ordered) + * - Links [text](url) (href stored in attribute) + * - Tables (pipe tables) with inline Markdown inside cells + * + * Usage: + * JTextPane pane = new JTextPane(); + * pane.setEditorKit(new MarkdownEditorKit()); + * ((MarkdownDocument)pane.getDocument()).setMarkdown(markdownString); + */ +public class MarkdownEditorKit extends StyledEditorKit { + private static final long serialVersionUID = 350927457022623776L; + + @Override + public String getContentType() { + return "text/markdown"; + } + + @Override + public Document createDefaultDocument() { + return new MarkdownDocument(); + } + + /** + * Set the Markdown contents of a JTextPane + * + * @param pane The JTextPane to set the contents of + * @param markdown The Markdown to set as the contents + */ + public void setMarkdown(JTextPane pane, String markdown) { + if (!(pane.getDocument() instanceof MarkdownDocument)) { + pane.setDocument(createDefaultDocument()); + } + ((MarkdownDocument) pane.getDocument()).setMarkdown(markdown); + } + + @Override + public void read(Reader in, Document doc, int pos) throws IOException, BadLocationException { + String markdown = readAll(in); + if (doc instanceof StyledDocument) { + MarkdownRenderer.render((StyledDocument) doc, markdown, pos, false); + } else { + super.read(new StringReader(markdown), doc, pos); + } + } + + @Override + public void write(Writer out, Document doc, int pos, int len) throws IOException, BadLocationException { + // Outputs rendered text, not original Markdown. + 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(); + } + + // ------------------------------------------------------------------------- + // Document + // ------------------------------------------------------------------------- + + /** + * A instance of {@link StyledDocument} for Markdown + */ + public static class MarkdownDocument extends DefaultStyledDocument { + private static final long serialVersionUID = 3602531682908814242L; + + /** + * The name for the source property + */ + public static final String PROP_SOURCE_MARKDOWN = "markdown.source"; + /** + * The name for the Link attribute + */ + public static final String ATTR_LINK_HREF = "markdown.link.href"; + + // Base / inline + final SimpleAttributeSet base = new SimpleAttributeSet(); + final SimpleAttributeSet bold = new SimpleAttributeSet(); + final SimpleAttributeSet italic = new SimpleAttributeSet(); + final SimpleAttributeSet code = new SimpleAttributeSet(); + final SimpleAttributeSet link = new SimpleAttributeSet(); + + // Headings + final SimpleAttributeSet h1 = new SimpleAttributeSet(); + final SimpleAttributeSet h2 = new SimpleAttributeSet(); + final SimpleAttributeSet h3 = new SimpleAttributeSet(); + + // Blockquote + final SimpleAttributeSet blockquoteText = new SimpleAttributeSet(); + final SimpleAttributeSet blockquotePara = new SimpleAttributeSet(); + + // List + final SimpleAttributeSet listPara = new SimpleAttributeSet(); + + // Code block + final SimpleAttributeSet codeBlockText = new SimpleAttributeSet(); + final SimpleAttributeSet codeBlockPara = new SimpleAttributeSet(); + + // Table + final SimpleAttributeSet tableText = new SimpleAttributeSet(); + final SimpleAttributeSet tableHeaderText = new SimpleAttributeSet(); + final SimpleAttributeSet tablePara = new SimpleAttributeSet(); + + /** + * Create a new Markdown document + */ + public MarkdownDocument() { + super(); + + StyleConstants.setFontSize(base, 13); + + StyleConstants.setBold(bold, true); + StyleConstants.setItalic(italic, true); + + StyleConstants.setFontFamily(code, "Monospaced"); + StyleConstants.setBackground(code, new Color(245, 245, 245)); + + StyleConstants.setForeground(link, new Color(0, 102, 204)); + StyleConstants.setUnderline(link, true); + + StyleConstants.setBold(h1, true); + StyleConstants.setFontSize(h1, 22); + + StyleConstants.setBold(h2, true); + StyleConstants.setFontSize(h2, 18); + + StyleConstants.setBold(h3, true); + StyleConstants.setFontSize(h3, 15); + + StyleConstants.setForeground(blockquoteText, new Color(90, 90, 90)); + StyleConstants.setItalic(blockquoteText, true); + + StyleConstants.setLeftIndent(blockquotePara, 18f); + StyleConstants.setSpaceAbove(blockquotePara, 3f); + StyleConstants.setSpaceBelow(blockquotePara, 3f); + + StyleConstants.setLeftIndent(listPara, 18f); + StyleConstants.setFirstLineIndent(listPara, -12f); + + StyleConstants.setFontFamily(codeBlockText, "Monospaced"); + StyleConstants.setBackground(codeBlockText, new Color(245, 245, 245)); + + StyleConstants.setLeftIndent(codeBlockPara, 18f); + StyleConstants.setSpaceAbove(codeBlockPara, 6f); + StyleConstants.setSpaceBelow(codeBlockPara, 6f); + + // Tables: render like a code-ish block + StyleConstants.setFontFamily(tableText, "Monospaced"); + StyleConstants.setBackground(tableText, new Color(245, 245, 245)); + + tableHeaderText.addAttributes(tableText); + StyleConstants.setBold(tableHeaderText, true); + + StyleConstants.setLeftIndent(tablePara, 18f); + StyleConstants.setSpaceAbove(tablePara, 6f); + StyleConstants.setSpaceBelow(tablePara, 6f); + } + + /** + * Set the contents of this document + * + * @param markdown The markdown to render + */ + public void setMarkdown(String markdown) { + putProperty(PROP_SOURCE_MARKDOWN, markdown); + try { + MarkdownRenderer.render(this, markdown, 0, true); + } catch (BadLocationException e) { + throw new RuntimeException(e); + } + } + } + + // ------------------------------------------------------------------------- + // Renderer + // ------------------------------------------------------------------------- + + static final class MarkdownRenderer { + + static void render(StyledDocument doc, String markdown, int argInsertPos, boolean clear) + throws BadLocationException { + + int insertPos = argInsertPos; + if (clear) { + doc.remove(0, doc.getLength()); + insertPos = 0; + } + + Attr a = Attr.from(doc); + + String[] lines = markdown.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); + + int offset = insertPos; + boolean inFence = false; + String fenceDelim = null; + + for (int li = 0; li < lines.length; li++) { + String line = lines[li]; + + // Fenced code blocks + String trimmed = line.trim(); + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + String delim = trimmed.substring(0, 3); + if (!inFence) { + inFence = true; + fenceDelim = delim; + } else if (delim.equals(fenceDelim)) { + inFence = false; + fenceDelim = null; + if (li != lines.length - 1) { + offset = insert(doc, offset, "\n", a.base); + } + } + continue; + } + + if (inFence) { + int start = offset; + offset = insert(doc, offset, line, a.codeBlockText); + offset = insert(doc, offset, "\n", a.codeBlockText); + applyParagraph(doc, start, offset - start, a.codeBlockPara); + continue; + } + + // Blank line + if (isBlank(line)) { + offset = insert(doc, offset, "\n", a.base); + continue; + } + + // Tables: detect header + divider + if (li + 1 < lines.length) { + TableHeader th = TableHeader.tryParse(line, lines[li + 1]); + if (th != null) { + // Collect rows + List> rows = new ArrayList<>(); + rows.add(th.headerCells); + + int r = li + 2; + while (r < lines.length && !isBlank(lines[r])) { + List cells = splitPipeRow(lines[r]); + if (cells == null || cells.size() != th.colCount) + break; + rows.add(cells); + r++; + } + + int start = offset; + offset = renderTable(doc, offset, rows, a); + applyParagraph(doc, start, offset - start, a.tablePara); + + li = r - 1; + continue; + } + } + + // Headings #, ##, ### + Heading h = Heading.parse(line); + if (h != null) { + AttributeSet hStyle; + if (h.level == 1) + hStyle = a.h1; + else if (h.level == 2) + hStyle = a.h2; + else + hStyle = a.h3; + + int start = offset; + offset = renderInline(doc, offset, h.text, a, hStyle); + offset = insert(doc, offset, "\n", a.base); + + SimpleAttributeSet para = new SimpleAttributeSet(); + StyleConstants.setSpaceAbove(para, 6f); + StyleConstants.setSpaceBelow(para, 4f); + applyParagraph(doc, start, offset - start, para); + continue; + } + + // Blockquote + if (line.startsWith(">")) { + String q = line; + while (q.startsWith(">")) + q = q.substring(1); + if (q.startsWith(" ")) + q = q.substring(1); + + int start = offset; + offset = insert(doc, offset, "▌ ", a.blockquoteText); + offset = renderInline(doc, offset, q, a, a.blockquoteText); + offset = insert(doc, offset, "\n", a.base); + applyParagraph(doc, start, offset - start, a.blockquotePara); + continue; + } + + // Lists + ListItem item = ListItem.parse(line); + if (item != null) { + int start = offset; + offset = insert(doc, offset, item.prefix, a.base); + offset = renderInline(doc, offset, item.text, a, a.base); + offset = insert(doc, offset, "\n", a.base); + applyParagraph(doc, start, offset - start, a.listPara); + continue; + } + + // Normal line = paragraph + int start = offset; + offset = renderInline(doc, offset, line, a, a.base); + offset = insert(doc, offset, "\n", a.base); + + SimpleAttributeSet para = new SimpleAttributeSet(); + StyleConstants.setSpaceBelow(para, 2f); + applyParagraph(doc, start, offset - start, para); + } + } + + // --- table rendering with inline markdown inside cells --- + + private static int renderTable(StyledDocument doc, int argOffset, List> rows, Attr a) + throws BadLocationException { + + int offset = argOffset; + int cols = rows.get(0).size(); + int[] widths = new int[cols]; + + // compute widths based on visible (rendered) length + for (List row : rows) { + for (int c = 0; c < cols; c++) { + String cell = safeCell(row, c); + int v = visibleLen(cell); + if (v > widths[c]) + widths[c] = v; + } + } + for (int c = 0; c < cols; c++) { + if (widths[c] < 3) + widths[c] = 3; + } + + // header row + offset = renderTableRow(doc, offset, rows.get(0), widths, a, a.tableHeaderText); + + // separator row (plain dashes) + offset = renderTableSeparator(doc, offset, widths, a); + + // body rows + for (int r = 1; r < rows.size(); r++) { + offset = renderTableRow(doc, offset, rows.get(r), widths, a, a.tableText); + } + + return offset; + } + + private static int renderTableRow(StyledDocument doc, int argOffset, List cells, int[] widths, Attr a, + AttributeSet rowStyle) throws BadLocationException { + + int offset = argOffset; + offset = insert(doc, offset, "|", rowStyle); + + for (int c = 0; c < widths.length; c++) { + String cell = safeCell(cells, c); + + offset = insert(doc, offset, " ", rowStyle); + + // Render inline markdown inside the cell using the table rowStyle as the base. + offset = renderInline(doc, offset, cell, a, rowStyle); + + int pad = widths[c] - visibleLen(cell); + if (pad > 0) { + offset = insert(doc, offset, repeat(' ', pad), rowStyle); + } + + offset = insert(doc, offset, " |", rowStyle); + } + + offset = insert(doc, offset, "\n", rowStyle); + return offset; + } + + private static int renderTableSeparator(StyledDocument doc, int argOffset, int[] widths, Attr a) + throws BadLocationException { + int offset = argOffset; + + AttributeSet s = a.tableText; + + offset = insert(doc, offset, "|", s); + for (int c = 0; c < widths.length; c++) { + offset = insert(doc, offset, " ", s); + offset = insert(doc, offset, repeat('-', widths[c]), s); + offset = insert(doc, offset, " |", s); + } + offset = insert(doc, offset, "\n", s); + return offset; + } + + private static String safeCell(List row, int idx) { + if (row == null) + return ""; + if (idx < 0 || idx >= row.size()) + return ""; + return row.get(idx); + } + + /** + * Splits a pipe table row into cells. - Allows optional leading/trailing pipes. + * - Does NOT split on escaped pipes (\|). - Does NOT split on pipes inside + * inline code spans (`...`). + */ + private static List splitPipeRow(String line) { + if (line == null) + return null; + if (line.indexOf('|') < 0) + return null; + + String s = line.trim(); + + if (s.startsWith("|")) + s = s.substring(1); + if (s.endsWith("|")) + s = s.substring(0, s.length() - 1); + + List cells = new ArrayList<>(); + StringBuilder cell = new StringBuilder(); + + boolean escaped = false; + boolean inCode = false; + + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + + if (escaped) { + cell.append(ch); + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '`') { + inCode = !inCode; + cell.append(ch); // keep backticks so inline parsing still sees them + continue; + } + + if (ch == '|' && !inCode) { + cells.add(cell.toString().trim()); + cell.setLength(0); + continue; + } + + cell.append(ch); + } + + if (escaped) + cell.append('\\'); + cells.add(cell.toString().trim()); + + return cells; + } + + // --- visible length for table width computation (basic markdown stripping) --- + + private static int visibleLen(String text) { + if (text == null || text.isEmpty()) + return 0; + + int i = 0; + int len = 0; + + while (i < text.length()) { + char c = text.charAt(i); + + // escape: \x counts as x + if (c == '\\' && i + 1 < text.length()) { + len += 1; + i += 2; + continue; + } + + // code span: `...` + if (c == '`') { + int end = findNext(text, i + 1, '`'); + if (end > i + 1) { + len += (end - (i + 1)); + i = end + 1; + continue; + } + } + + // link: [label](url) => count label + if (c == '[') { + int closeBracket = text.indexOf(']', i + 1); + if (closeBracket > i + 1 && closeBracket + 1 < text.length() + && text.charAt(closeBracket + 1) == '(') { + int closeParen = text.indexOf(')', closeBracket + 2); + if (closeParen > closeBracket + 2) { + String label = text.substring(i + 1, closeBracket); + len += visibleLen(label); + i = closeParen + 1; + continue; + } + } + } + + // bold: **...** or __...__ + if (startsWith(text, i, "**") || startsWith(text, i, "__")) { + String delim = text.substring(i, i + 2); + int end = text.indexOf(delim, i + 2); + if (end > i + 2) { + String inner = text.substring(i + 2, end); + len += visibleLen(inner); + i = end + 2; + continue; + } + } + + // italic: *...* or _..._ + if (c == '*' || c == '_') { + int end = text.indexOf(c, i + 1); + if (end > i + 1) { + String inner = text.substring(i + 1, end); + if (!isBlank(inner)) { + len += visibleLen(inner); + i = end + 1; + continue; + } + } + } + + len += 1; + i += 1; + } + + return len; + } + + private static int findNext(String s, int start, char needle) { + // Finds next unescaped needle. + boolean escaped = false; + for (int i = start; i < s.length(); i++) { + char c = s.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == needle) + return i; + } + return -1; + } + + // --- inline rendering --- + + private static int insert(StyledDocument doc, int offset, String s, AttributeSet attrs) + throws BadLocationException { + doc.insertString(offset, s, attrs); + return offset + s.length(); + } + + private static void applyParagraph(StyledDocument doc, int start, int length, AttributeSet paraAttrs) { + doc.setParagraphAttributes(start, Math.max(1, length), paraAttrs, false); + } + + /** + * Render inline Markdown inside a single line. Priority: - code spans `...` - + * links [text](url) - bold **...** or __...__ - italic *...* or _..._ + */ + private static int renderInline(StyledDocument doc, int argOffset, String text, Attr a, AttributeSet baseStyle) + throws BadLocationException { + int offset = argOffset; + + if (text == null || text.isEmpty()) + return offset; + + int i = 0; + StringBuilder plain = new StringBuilder(); + + while (i < text.length()) { + char c = text.charAt(i); + + // Escape + if (c == '\\' && i + 1 < text.length()) { + plain.append(text.charAt(i + 1)); + i += 2; + continue; + } + + // Code span + if (c == '`') { + int end = text.indexOf('`', i + 1); + if (end > i + 1) { + offset = flushPlain(doc, offset, plain, baseStyle); + String code = text.substring(i + 1, end); + offset = insert(doc, offset, code, merge(baseStyle, a.code)); + i = end + 1; + continue; + } + } + + // Link [text](url) + if (c == '[') { + int closeBracket = text.indexOf(']', i + 1); + if (closeBracket > i + 1 && closeBracket + 1 < text.length() + && text.charAt(closeBracket + 1) == '(') { + int closeParen = text.indexOf(')', closeBracket + 2); + if (closeParen > closeBracket + 2) { + String label = text.substring(i + 1, closeBracket); + String href = text.substring(closeBracket + 2, closeParen); + + offset = flushPlain(doc, offset, plain, baseStyle); + + SimpleAttributeSet linkAttrs = new SimpleAttributeSet(); + linkAttrs.addAttributes(baseStyle); + linkAttrs.addAttributes(a.link); + linkAttrs.addAttribute(MarkdownDocument.ATTR_LINK_HREF, href); + + offset = insert(doc, offset, label, linkAttrs); + i = closeParen + 1; + continue; + } + } + } + + // Bold **...** or __...__ + if (startsWith(text, i, "**") || startsWith(text, i, "__")) { + String delim = text.substring(i, i + 2); + int end = text.indexOf(delim, i + 2); + if (end > i + 2) { + offset = flushPlain(doc, offset, plain, baseStyle); + String content = text.substring(i + 2, end); + offset = insert(doc, offset, content, merge(baseStyle, a.bold)); + i = end + 2; + continue; + } + } + + // Italic *...* or _..._ + if (c == '*' || c == '_') { + int end = text.indexOf(c, i + 1); + if (end > i + 1) { + String content = text.substring(i + 1, end); + if (!isBlank(content)) { + offset = flushPlain(doc, offset, plain, baseStyle); + offset = insert(doc, offset, content, merge(baseStyle, a.italic)); + i = end + 1; + continue; + } + } + } + + // Default: plain + plain.append(c); + i++; + } + + return flushPlain(doc, offset, plain, baseStyle); + } + + private static int flushPlain(StyledDocument doc, int argOffset, StringBuilder plain, AttributeSet baseStyle) + throws BadLocationException { + int offset = argOffset; + + if (plain.length() == 0) + return offset; + offset = insert(doc, offset, plain.toString(), baseStyle); + plain.setLength(0); + return offset; + } + + private static boolean startsWith(String s, int i, String prefix) { + return i + prefix.length() <= s.length() && s.regionMatches(i, prefix, 0, prefix.length()); + } + + private static AttributeSet merge(AttributeSet base, AttributeSet extra) { + SimpleAttributeSet merged = new SimpleAttributeSet(); + merged.addAttributes(base); + merged.addAttributes(extra); + return merged; + } + + // --- line parsers / helpers --- + + private static boolean isBlank(String s) { + if (s == null || s.isEmpty()) + return true; + for (int i = 0; i < s.length(); i++) { + if (!Character.isWhitespace(s.charAt(i))) + return false; + } + return true; + } + + private static String repeat(char ch, int count) { + if (count <= 0) + return ""; + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) + sb.append(ch); + return sb.toString(); + } + + private static String stripLeading(String s) { + if (s == null) + return ""; + int i = 0; + while (i < s.length() && Character.isWhitespace(s.charAt(i))) + i++; + return s.substring(i); + } + + private static final class Heading { + final int level; + final String text; + + Heading(int level, String text) { + this.level = level; + this.text = text; + } + + static Heading parse(String line) { + if (line == null || line.isEmpty()) + return null; + int i = 0; + while (i < line.length() && line.charAt(i) == '#') + i++; + if (i == 0 || i > 6) + return null; + if (i < line.length() && line.charAt(i) == ' ') { + String t = line.substring(i + 1); + int level = i; + if (level > 3) + level = 3; // cap for styling + return new Heading(level, t); + } + return null; + } + } + + private static final class ListItem { + final String prefix; + final String text; + + ListItem(String prefix, String text) { + this.prefix = prefix; + this.text = text; + } + + static ListItem parse(String line) { + String s = stripLeading(line); + + if (s.startsWith("- ") || s.startsWith("* ") || s.startsWith("+ ")) { + return new ListItem("• ", s.substring(2)); + } + + int n = 0; + while (n < s.length() && Character.isDigit(s.charAt(n))) + n++; + if (n > 0 && n + 1 < s.length()) { + char sep = s.charAt(n); + if ((sep == '.' || sep == ')') && s.charAt(n + 1) == ' ') { + String num = s.substring(0, n); + return new ListItem(num + sep + " ", s.substring(n + 2)); + } + } + + return null; + } + } + + private static final class TableHeader { + final List headerCells; + final int colCount; + + TableHeader(List headerCells, int colCount) { + this.headerCells = headerCells; + this.colCount = colCount; + } + + static TableHeader tryParse(String headerLine, String dividerLine) { + List header = splitPipeRow(headerLine); + if (header == null || header.isEmpty()) + return null; + + List div = splitPipeRow(dividerLine); + if (div == null || div.size() != header.size()) + return null; + + for (int i = 0; i < div.size(); i++) { + String d = div.get(i).replace(" ", ""); + // allow :---:, ---:, :---, --- + if (!d.matches(":?-{3,}:?")) + return null; + } + + return new TableHeader(header, header.size()); + } + } + + private static final class Attr { + final SimpleAttributeSet base; + final SimpleAttributeSet bold; + final SimpleAttributeSet italic; + final SimpleAttributeSet code; + final SimpleAttributeSet link; + + final SimpleAttributeSet h1; + final SimpleAttributeSet h2; + final SimpleAttributeSet h3; + + final SimpleAttributeSet blockquoteText; + final SimpleAttributeSet blockquotePara; + + final SimpleAttributeSet listPara; + + final SimpleAttributeSet codeBlockText; + final SimpleAttributeSet codeBlockPara; + + final SimpleAttributeSet tableText; + final SimpleAttributeSet tableHeaderText; + final SimpleAttributeSet tablePara; + + Attr(SimpleAttributeSet base, SimpleAttributeSet bold, SimpleAttributeSet italic, SimpleAttributeSet code, + SimpleAttributeSet link, SimpleAttributeSet h1, SimpleAttributeSet h2, SimpleAttributeSet h3, + SimpleAttributeSet blockquoteText, SimpleAttributeSet blockquotePara, SimpleAttributeSet listPara, + SimpleAttributeSet codeBlockText, SimpleAttributeSet codeBlockPara, SimpleAttributeSet tableText, + SimpleAttributeSet tableHeaderText, SimpleAttributeSet tablePara) { + this.base = base; + this.bold = bold; + this.italic = italic; + this.code = code; + this.link = link; + this.h1 = h1; + this.h2 = h2; + this.h3 = h3; + this.blockquoteText = blockquoteText; + this.blockquotePara = blockquotePara; + this.listPara = listPara; + this.codeBlockText = codeBlockText; + this.codeBlockPara = codeBlockPara; + this.tableText = tableText; + this.tableHeaderText = tableHeaderText; + this.tablePara = tablePara; + } + + static Attr from(StyledDocument doc) { + if (doc instanceof MarkdownDocument) { + MarkdownDocument md = (MarkdownDocument) doc; + return new Attr(md.base, md.bold, md.italic, md.code, md.link, md.h1, md.h2, md.h3, + md.blockquoteText, md.blockquotePara, md.listPara, md.codeBlockText, md.codeBlockPara, + md.tableText, md.tableHeaderText, md.tablePara); + } + + // fallback defaults + SimpleAttributeSet base = new SimpleAttributeSet(); + StyleConstants.setFontSize(base, 13); + + SimpleAttributeSet bold = new SimpleAttributeSet(); + StyleConstants.setBold(bold, true); + + SimpleAttributeSet italic = new SimpleAttributeSet(); + StyleConstants.setItalic(italic, true); + + SimpleAttributeSet code = new SimpleAttributeSet(); + StyleConstants.setFontFamily(code, "Monospaced"); + StyleConstants.setBackground(code, new Color(245, 245, 245)); + + SimpleAttributeSet link = new SimpleAttributeSet(); + StyleConstants.setForeground(link, new Color(0, 102, 204)); + StyleConstants.setUnderline(link, true); + + SimpleAttributeSet h1 = new SimpleAttributeSet(); + StyleConstants.setBold(h1, true); + StyleConstants.setFontSize(h1, 22); + + SimpleAttributeSet h2 = new SimpleAttributeSet(); + StyleConstants.setBold(h2, true); + StyleConstants.setFontSize(h2, 18); + + SimpleAttributeSet h3 = new SimpleAttributeSet(); + StyleConstants.setBold(h3, true); + StyleConstants.setFontSize(h3, 15); + + SimpleAttributeSet bqt = new SimpleAttributeSet(); + StyleConstants.setForeground(bqt, new Color(90, 90, 90)); + StyleConstants.setItalic(bqt, true); + + SimpleAttributeSet bqPara = new SimpleAttributeSet(); + StyleConstants.setLeftIndent(bqPara, 18f); + + SimpleAttributeSet listPara = new SimpleAttributeSet(); + StyleConstants.setLeftIndent(listPara, 18f); + StyleConstants.setFirstLineIndent(listPara, -12f); + + SimpleAttributeSet cbText = new SimpleAttributeSet(); + StyleConstants.setFontFamily(cbText, "Monospaced"); + StyleConstants.setBackground(cbText, new Color(245, 245, 245)); + + SimpleAttributeSet cbPara = new SimpleAttributeSet(); + StyleConstants.setLeftIndent(cbPara, 18f); + + SimpleAttributeSet tableText = new SimpleAttributeSet(); + StyleConstants.setFontFamily(tableText, "Monospaced"); + StyleConstants.setBackground(tableText, new Color(245, 245, 245)); + + SimpleAttributeSet tableHeader = new SimpleAttributeSet(); + tableHeader.addAttributes(tableText); + StyleConstants.setBold(tableHeader, true); + + SimpleAttributeSet tablePara = new SimpleAttributeSet(); + StyleConstants.setLeftIndent(tablePara, 18f); + + return new Attr(base, bold, italic, code, link, h1, h2, h3, bqt, bqPara, listPara, cbText, cbPara, + tableText, tableHeader, tablePara); + } + } + } +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java new file mode 100644 index 0000000..3a1c72e --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java @@ -0,0 +1,374 @@ +package bjc.utils.gui; + +import javax.swing.*; +import javax.swing.text.*; +import java.awt.*; +import java.awt.event.*; +import java.net.URI; + +public final class MarkdownLinkSupport { + + @FunctionalInterface + public interface LinkHandler { + void onLinkActivated(String href); + } + + /** + * Provides glossary behavior for glossary:// links. + * + * The "key" is whatever you decide (term ID, slug, etc.) extracted from the + * URI. + */ + public interface GlossaryProvider { + /** + * Return a user-facing definition for tooltip display, or null if unknown. You + * can return plain text; it will be wrapped/escaped into HTML. + */ + String getDefinition(String key); + + /** + * Called when user clicks a glossary:// link. + */ + void openEntry(String key); + } + + private MarkdownLinkSupport() { + } + + // --------------------------------------------------------------------- + // Existing behavior (browser links only) + // --------------------------------------------------------------------- + + public static void install(JTextPane pane) { + install(pane, MarkdownLinkSupport::openInBrowserBestEffort, null, "glossary"); + } + + public static void install(JTextPane pane, LinkHandler handler) { + install(pane, handler, null, "glossary"); + } + + // --------------------------------------------------------------------- + // New behavior (browser links + glossary links) + // --------------------------------------------------------------------- + + /** + * Installs link hover/click behavior. + * + * @param pane the JTextPane containing styled text + * @param defaultHandler handler for normal links (http, https, mailto, etc.) + * @param glossary optional glossary provider for glossary-scheme links + * @param glossaryScheme scheme name (e.g. "glossary" => glossary://term) + */ + public static void install(JTextPane pane, LinkHandler defaultHandler, GlossaryProvider glossary, + String glossaryScheme) { + if (pane == null) + throw new IllegalArgumentException("pane == null"); + if (defaultHandler == null) + throw new IllegalArgumentException("defaultHandler == null"); + if (glossaryScheme == null || glossaryScheme.trim().isEmpty()) + glossaryScheme = "glossary"; + + final Cursor defaultCursor = pane.getCursor(); + final Cursor handCursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); + + final String schemeLower = glossaryScheme.toLowerCase(); + + // Simple hover caching so we don't re-set tooltip/cursor every pixel of + // movement + final HoverState hoverState = new HoverState(); + + MouseMotionListener motion = new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + String href = hrefAtPoint(pane, e.getPoint()); + + if (href != null && href.equals(hoverState.lastHref)) { + return; // no change + } + hoverState.lastHref = href; + + if (href == null) { + resetHover(pane, defaultCursor); + return; + } + + if (pane.getCursor() != handCursor) + pane.setCursor(handCursor); + + // Tooltip decision + String tip = tooltipForHref(href, glossary, schemeLower); + pane.setToolTipText(tip); + } + }; + + MouseListener click = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (!SwingUtilities.isLeftMouseButton(e)) + return; + if (e.getClickCount() != 1) + return; + + String href = hrefAtPoint(pane, e.getPoint()); + if (href == null) + return; + + if (isScheme(href, schemeLower) && glossary != null) { + String key = extractGlossaryKey(href, schemeLower); + if (key != null && !key.isEmpty()) { + glossary.openEntry(key); + e.consume(); + return; + } + } + + // default + defaultHandler.onLinkActivated(href); + e.consume(); + } + + @Override + public void mouseExited(MouseEvent e) { + hoverState.lastHref = null; + resetHover(pane, defaultCursor); + } + }; + + pane.addMouseMotionListener(motion); + pane.addMouseListener(click); + } + + private static void resetHover(JTextPane pane, Cursor defaultCursor) { + if (pane.getCursor() != defaultCursor) + pane.setCursor(defaultCursor); + pane.setToolTipText(null); + } + + private static String tooltipForHref(String href, GlossaryProvider glossary, String glossarySchemeLower) { + if (isScheme(href, glossarySchemeLower) && glossary != null) { + String key = extractGlossaryKey(href, glossarySchemeLower); + if (key == null || key.isEmpty()) + return null; + + String def = glossary.getDefinition(key); + if (def == null || def.trim().isEmpty()) { + // Fallback tooltip if you want: show the key + return htmlTooltip("Glossary: " + key); + } + return htmlTooltip(def); + } + + // Default behavior: show the href + return href; + } + + private static boolean isScheme(String href, String schemeLower) { + try { + URI uri = new URI(href); + String scheme = uri.getScheme(); + return scheme != null && schemeLower.equalsIgnoreCase(scheme); + } catch (Exception ignored) { + return false; + } + } + + /** + * Extract your glossary “key” from a glossary:// URI. + * + * Accepts: glossary://TERM glossary://TERM/some/subpath glossary:TERM (also + * works, if you ever emit that) + * + * Returns something stable to use as lookup key. + */ + private static String extractGlossaryKey(String href, String schemeLower) { + try { + URI uri = new URI(href); + String scheme = uri.getScheme(); + if (scheme == null || !schemeLower.equalsIgnoreCase(scheme)) + return null; + + // Preferred: glossary://host/path + String host = uri.getHost(); + String path = uri.getPath(); + + if (host != null && !host.isEmpty()) { + if (path != null && !path.isEmpty() && !"/".equals(path)) { + return host + path; // e.g. TERM/sub + } + return host; // TERM + } + + // Fallback: glossary:TERM or other scheme-specific forms + String ssp = uri.getSchemeSpecificPart(); + if (ssp == null) + return null; + + // strip leading // if present + if (ssp.startsWith("//")) + ssp = ssp.substring(2); + + // strip query/fragment if someone includes them + int q = ssp.indexOf('?'); + if (q >= 0) + ssp = ssp.substring(0, q); + int f = ssp.indexOf('#'); + if (f >= 0) + ssp = ssp.substring(0, f); + + return ssp.trim(); + } catch (Exception ignored) { + // If href isn't a valid URI, you could still support a raw prefix check: + String prefix = schemeLower + "://"; + if (href != null && href.toLowerCase().startsWith(prefix)) { + return href.substring(prefix.length()).trim(); + } + return null; + } + } + + // --------------------------------------------------------------------- + // Link attribute lookup in the StyledDocument + // --------------------------------------------------------------------- + + private static String hrefAtPoint(JTextPane pane, Point p) { + Document doc = pane.getDocument(); + if (!(doc instanceof StyledDocument)) + return null; + + int pos = viewToModelCompat(pane, p); + if (pos < 0 || pos >= doc.getLength()) + return null; + + return findHrefAtOffset((StyledDocument) doc, pos); + } + + private static int viewToModelCompat(JTextPane pane, Point p) { + // Java 8 friendly + try { + return pane.viewToModel(p); + } catch (Exception ignored) { + return -1; + } + } + + private static String findHrefAtOffset(StyledDocument doc, int offset) { + Element charElem = doc.getCharacterElement(offset); + String href = getHrefFromAttributes(charElem.getAttributes()); + if (href != null) + return href; + + // Sometimes attributes land on parent elements + Element parent = charElem.getParentElement(); + while (parent != null) { + href = getHrefFromAttributes(parent.getAttributes()); + if (href != null) + return href; + parent = parent.getParentElement(); + } + return null; + } + + private static String getHrefFromAttributes(AttributeSet attrs) { + Object v = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF); + return (v instanceof String) ? (String) v : null; + } + + // --------------------------------------------------------------------- + // Default “open in browser” handler + // --------------------------------------------------------------------- + + private static void openInBrowserBestEffort(String href) { + if (href == null || href.trim().isEmpty()) + return; + + if (tryBrowse(href)) + return; + + // If missing scheme, try https:// + if (!href.contains("://") && !href.startsWith("mailto:")) { + tryBrowse("https://" + href); + } + } + + private static boolean tryBrowse(String href) { + try { + if (!Desktop.isDesktopSupported()) + return false; + Desktop d = Desktop.getDesktop(); + if (!d.isSupported(Desktop.Action.BROWSE)) + return false; + + d.browse(new URI(href)); + return true; + } catch (Exception ignored) { + return false; + } + } + + // --------------------------------------------------------------------- + // Tooltip formatting + // --------------------------------------------------------------------- + + /** + * Swing tooltips support HTML. This wraps and escapes your glossary text. You + * can tune the width (e.g. 320px) to make definitions readable. + */ + private static String htmlTooltip(String text) { + String escaped = escapeHtml(text); + // Convert newlines to
for multi-line definitions + escaped = escaped.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "
"); + return "
" + escaped + "
"; + } + + private static String escapeHtml(String s) { + if (s == null) + return ""; + StringBuilder sb = new StringBuilder(s.length() + 32); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append("""); + break; + case '\'': + sb.append("'"); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + + private static final class HoverState { + String lastHref; + } +} + +/* + * JTextPane pane = new JTextPane(); pane.setEditorKit(new MarkdownEditorKit()); + * ((MarkdownEditorKit.MarkdownDocument)pane.getDocument()).setMarkdown(markdown + * ); + * + * // Plug in your glossary: MarkdownLinkSupport.install( pane, href -> + * System.out.println("normal link clicked: " + href), // or open in browser new + * MarkdownLinkSupport.GlossaryProvider() { + * + * @Override public String getDefinition(String key) { // Look up from your + * glossary map/DB if ("Stalk".equalsIgnoreCase(key)) { return + * "Stalk: At the beginning of your end step, if an opponent was dealt damage..." + * ; } return null; } + * + * @Override public void openEntry(String key) { // Navigate in your app (select + * glossary panel, jump to entry, etc.) + * System.out.println("open glossary entry: " + key); } }, "glossary" ); + */ \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java new file mode 100644 index 0000000..a06ede0 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java @@ -0,0 +1,457 @@ +package bjc.utils.gui; + +import javax.swing.*; +import javax.swing.event.CaretEvent; +import javax.swing.event.CaretListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.*; +import java.awt.*; +import java.awt.event.*; +import java.net.URI; + +public final class MarkdownSplitPreview extends JPanel { + + private static final int RENDER_DEBOUNCE_MS = 150; + private static final int CARET_SYNC_DEBOUNCE_MS = 75; + + // If true: when the user scrolls the preview, automatically turn off "Follow + // caret". + private static final boolean AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL = true; + + private final JTextArea editor = new JTextArea(); + private final JTextPane preview = new JTextPane(); + + private final MarkdownEditorKit previewKit = new MarkdownEditorKit(); + + private final Timer renderTimer; + private final Timer caretSyncTimer; + + private String lastRenderedMarkdown = null; + + // Reentrancy guard for programmatic scrollbar updates + private boolean adjustingScrollbars = false; + + // Follow-caret UI state + private final JToggleButton followCaretToggle = new JToggleButton("Follow caret", true); + + public MarkdownSplitPreview() { + super(new BorderLayout()); + + // Toolbar + JToolBar tb = new JToolBar(); + tb.setFloatable(false); + + followCaretToggle.setFocusable(false); + followCaretToggle.setToolTipText("When enabled, the preview tracks where you are editing."); + tb.add(followCaretToggle); + + JButton renderNowButton = new JButton("Render now"); + renderNowButton.setFocusable(false); + renderNowButton.setToolTipText("Force an immediate preview update (Ctrl+Enter / Ctrl+R)."); + tb.addSeparator(); + tb.add(renderNowButton); + + add(tb, BorderLayout.NORTH); + + // Editor (left) + editor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + editor.setTabSize(4); + editor.setLineWrap(true); + editor.setWrapStyleWord(true); + + // Preview (right) + preview.setEditable(false); + preview.setEditorKit(previewKit); + + final JScrollPane leftScroll = new JScrollPane(editor); + final JScrollPane rightScroll = new JScrollPane(preview); + + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll, rightScroll); + split.setResizeWeight(0.5); + add(split, BorderLayout.CENTER); + + // Debounced render (EDT) + renderTimer = new Timer(RENDER_DEBOUNCE_MS, e -> renderPreview(rightScroll, leftScroll)); + renderTimer.setRepeats(false); + + // Debounced caret-follow (EDT) + caretSyncTimer = new Timer(CARET_SYNC_DEBOUNCE_MS, e -> maybeSyncPreviewToCaret(leftScroll, rightScroll)); + caretSyncTimer.setRepeats(false); + + // Typing/editing triggers render + (optional) caret-follow + editor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + schedule(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + schedule(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + schedule(); + } + + private void schedule() { + renderTimer.restart(); + caretSyncTimer.restart(); + } + }); + + // Caret movement should also move preview (if follow enabled) + editor.addCaretListener(new CaretListener() { + @Override + public void caretUpdate(CaretEvent e) { + if (!editor.isFocusOwner()) + return; + caretSyncTimer.restart(); + } + }); + + // Button triggers immediate render + renderNowButton.addActionListener(e -> renderNow(rightScroll, leftScroll)); + + installRenderNowHotkeys(rightScroll, leftScroll); + installFollowCaretHotkey(); // Ctrl+L + installLinkHandling(preview); + installScrollSync(leftScroll, rightScroll); + installPreviewManualScrollDetection(rightScroll); + + // Initial render + renderPreview(rightScroll, leftScroll); + } + + public void setMarkdown(String markdown) { + editor.setText(markdown == null ? "" : markdown); + renderTimer.stop(); + caretSyncTimer.stop(); + JScrollPane rightScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, preview); + JScrollPane leftScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, editor); + if (rightScroll != null && leftScroll != null) { + renderPreview(rightScroll, leftScroll); + } else { + previewKit.setMarkdown(preview, markdown == null ? "" : markdown); + lastRenderedMarkdown = markdown; + } + } + + public String getMarkdown() { + return editor.getText(); + } + + public boolean isFollowCaretEnabled() { + return followCaretToggle.isSelected(); + } + + public void setFollowCaretEnabled(boolean enabled) { + followCaretToggle.setSelected(enabled); + } + + // ------------------------------------------------------------------------- + // Render logic + // ------------------------------------------------------------------------- + + private void renderPreview(JScrollPane previewScroll, JScrollPane editorScroll) { + String md = editor.getText(); + if (md == null) + md = ""; + + // Skip rerender if unchanged + if (md.equals(lastRenderedMarkdown)) { + maybeSyncPreviewToCaret(editorScroll, previewScroll); + return; + } + + lastRenderedMarkdown = md; + + try { + previewKit.setMarkdown(preview, md); + } catch (RuntimeException ex) { + preview.setDocument(new DefaultStyledDocument()); + preview.setText("Markdown render error:\n" + ex.getMessage()); + } + + SwingUtilities.invokeLater(() -> maybeSyncPreviewToCaret(editorScroll, previewScroll)); + } + + private void renderNow(JScrollPane previewScroll, JScrollPane editorScroll) { + renderTimer.stop(); + renderPreview(previewScroll, editorScroll); + } + + // ------------------------------------------------------------------------- + // Hotkeys + // ------------------------------------------------------------------------- + + private void installRenderNowHotkeys(JScrollPane previewScroll, JScrollPane editorScroll) { + InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = editor.getActionMap(); + + Action renderNowAction = new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + renderNow(previewScroll, editorScroll); + } + }; + + // Ctrl+Enter + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), "md.renderNow"); + // Ctrl+R + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.CTRL_DOWN_MASK), "md.renderNow"); + + am.put("md.renderNow", renderNowAction); + } + + private void installFollowCaretHotkey() { + InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = editor.getActionMap(); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_L, InputEvent.CTRL_DOWN_MASK), "md.toggleFollowCaret"); + am.put("md.toggleFollowCaret", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + followCaretToggle.setSelected(!followCaretToggle.isSelected()); + } + }); + } + + // ------------------------------------------------------------------------- + // Clickable links in preview + // ------------------------------------------------------------------------- + + private void installLinkHandling(JTextPane pane) { + pane.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + String href = hrefAtPoint(pane, e.getPoint()); + pane.setCursor( + href != null ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : Cursor.getDefaultCursor()); + } + }); + + pane.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1 || e.getClickCount() != 1) + return; + String href = hrefAtPoint(pane, e.getPoint()); + if (href != null) + openLink(href, pane); + } + }); + } + + private static String hrefAtPoint(JTextPane pane, Point p) { + int pos = pane.viewToModel(p); // Java 8 compatible + if (pos < 0) + return null; + + Document d = pane.getDocument(); + if (!(d instanceof StyledDocument)) + return null; + + StyledDocument sd = (StyledDocument) d; + Element elem = sd.getCharacterElement(pos); + if (elem == null) + return null; + + AttributeSet attrs = elem.getAttributes(); + Object href = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF); + return (href instanceof String) ? (String) href : null; + } + + private static void openLink(String href, Component parent) { + try { + if (!Desktop.isDesktopSupported()) { + Toolkit.getDefaultToolkit().beep(); + return; + } + Desktop.getDesktop().browse(new URI(href)); + } catch (Exception ex) { + JOptionPane.showMessageDialog(parent, "Could not open link:\n" + href + "\n\n" + ex.getMessage(), + "Open Link Failed", JOptionPane.ERROR_MESSAGE); + } + } + + // ------------------------------------------------------------------------- + // Manual preview scroll detection (optionally disables follow-caret) + // ------------------------------------------------------------------------- + + private void installPreviewManualScrollDetection(JScrollPane previewScroll) { + JScrollBar bar = previewScroll.getVerticalScrollBar(); + + // Mouse wheel in preview => manual + MouseWheelListener wheel = e -> onPreviewManualScroll(); + previewScroll.addMouseWheelListener(wheel); + previewScroll.getViewport().addMouseWheelListener(wheel); + + // Scrollbar drag/click => manual + bar.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + onPreviewManualScroll(); + } + + @Override + public void mouseReleased(MouseEvent e) { + onPreviewManualScroll(); + } + }); + + // Any user adjustment (not programmatic) => manual + bar.addAdjustmentListener(e -> { + if (adjustingScrollbars) + return; + if (e.getValueIsAdjusting()) + onPreviewManualScroll(); + }); + } + + private void onPreviewManualScroll() { + if (AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL && followCaretToggle.isSelected()) { + followCaretToggle.setSelected(false); + } + } + + // ------------------------------------------------------------------------- + // Scroll sync (proportional, bidirectional) + // ------------------------------------------------------------------------- + + private void installScrollSync(JScrollPane editorScroll, JScrollPane previewScroll) { + JScrollBar eBar = editorScroll.getVerticalScrollBar(); + JScrollBar pBar = previewScroll.getVerticalScrollBar(); + + // Editor scrolled => move preview only if follow is enabled + eBar.addAdjustmentListener(ev -> { + if (adjustingScrollbars) + return; + if (!followCaretToggle.isSelected()) + return; + syncFromTo(eBar, pBar); + }); + + // Preview scrolled => move editor always (user intent) + pBar.addAdjustmentListener(ev -> { + if (adjustingScrollbars) + return; + syncFromTo(pBar, eBar); + }); + } + + private void syncFromTo(JScrollBar from, JScrollBar to) { + int fromRange = scrollRange(from); + int toRange = scrollRange(to); + if (fromRange <= 0 || toRange <= 0) + return; + + double frac = from.getValue() / (double) fromRange; + int target = (int) Math.round(frac * toRange); + target = clamp(target, 0, toRange); + + if (to.getValue() == target) + return; + + adjustingScrollbars = true; + try { + to.setValue(target); + } finally { + adjustingScrollbars = false; + } + } + + private static int scrollRange(JScrollBar bar) { + int range = bar.getMaximum() - bar.getVisibleAmount(); + return Math.max(0, range); + } + + private static int clamp(int v, int lo, int hi) { + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; + } + + // ------------------------------------------------------------------------- + // Caret-follow preview syncing (when enabled) + // ------------------------------------------------------------------------- + + private void maybeSyncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) { + if (!followCaretToggle.isSelected()) + return; + if (!editor.isFocusOwner()) + return; + + syncPreviewToCaret(editorScroll, previewScroll); + } + + private void syncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) { + JScrollBar pBar = previewScroll.getVerticalScrollBar(); + int pRange = scrollRange(pBar); + if (pRange <= 0) + return; + + int caret = editor.getCaretPosition(); + Rectangle caretRect; + try { + caretRect = editor.modelToView(caret); // Java 8 + } catch (BadLocationException e) { + return; + } + if (caretRect == null) + return; + + int prefHeight = editor.getPreferredSize().height; + int visibleHeight = editorScroll.getViewport().getExtentSize().height; + + int eRange = Math.max(0, prefHeight - visibleHeight); + if (eRange <= 0) + return; + + double frac = caretRect.y / (double) eRange; + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + int target = (int) Math.round(frac * pRange); + target = clamp(target, 0, pRange); + + if (pBar.getValue() == target) + return; + + adjustingScrollbars = true; + try { + pBar.setValue(target); + } finally { + adjustingScrollbars = false; + } + } + + // ------------------------------------------------------------------------- + // Demo + // ------------------------------------------------------------------------- + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + JFrame f = new JFrame("Markdown Editor + Preview"); + f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + + MarkdownSplitPreview panel = new MarkdownSplitPreview(); + panel.setMarkdown("# Hello\n\n" + "Type Markdown on the left.\n\n" + + "A link: [example](https://example.com)\n\n" + "| Col A | Col B |\n" + "|------:|:-----|\n" + + "| **bold** | `code` |\n" + "| [link](https://example.com) | *italic* |\n\n" + + "If you scroll the preview, Follow caret will turn off.\n" + + "Toggle it back on with the button or Ctrl+L.\n"); + + f.setContentPane(panel); + f.setSize(1100, 700); + f.setLocationRelativeTo(null); + f.setVisible(true); + }); + } +} diff --git a/base/src/main/java/bjc/utils/misc/SmartJSONFormatter.java b/base/src/main/java/bjc/utils/misc/SmartJSONFormatter.java new file mode 100644 index 0000000..4873b62 --- /dev/null +++ b/base/src/main/java/bjc/utils/misc/SmartJSONFormatter.java @@ -0,0 +1,520 @@ +package bjc.utils.misc; + +import java.util.ArrayList; +import java.util.List; + +/** + * SmartJsonFormatter + * + * Pretty-prints JSON while keeping short/simple objects and arrays on a single line. + * + * Heuristics: + * - Empty containers print inline: {} and []. + * - Containers whose elements/values are all primitives (string, number, true, false, null) + * and whose inline form fits within the remaining line budget are printed inline. + * - Otherwise, containers are expanded. For arrays of primitives that don't fit on one line, + * elements are packed into multiple lines with wrapping up to maxWidth. + * + * Configuration knobs: + * - maxWidth: target maximum characters per line (default 100). + * - indentSize: spaces per indent level (default 2). + * - inlineMaxElements: hard cap of elements/fields allowed for inline (default 8). + * + * Usage: + * String pretty = SmartJsonFormatter.format(jsonString); + * // or with custom settings: + * String pretty = new SmartJsonFormatter(100, 2, 8).formatSmart(jsonString); + * + * Notes: + * - Strict JSON only (no comments, no trailing commas). + * - Returns the original text on parse error when using format(); throws otherwise via formatSmart(). + */ +public class SmartJSONFormatter { + + private final int maxWidth; + private final int indentSize; + private final int inlineMaxElements; + + public SmartJSONFormatter() { + this(100, 2, 8); + } + + public SmartJSONFormatter(int maxWidth, int indentSize, int inlineMaxElements) { + this.maxWidth = Math.max(40, maxWidth); + this.indentSize = Math.max(0, indentSize); + this.inlineMaxElements = Math.max(1, inlineMaxElements); + } + + /** Safe helper: returns original JSON if parsing/formatting fails. */ + public static String format(String json) { + try { + return new SmartJSONFormatter().formatSmart(json); + } catch (Exception e) { + return json; // fail-safe: don't destructively change input if something is off + } + } + + /** Strict formatter: throws on invalid JSON. */ + public String formatSmart(String json) { + Tokenizer tz = new Tokenizer(json); + Parser p = new Parser(tz); + Node root = p.parse(); + StringBuilder out = new StringBuilder(json.length() + 64); + render(root, 0, 0, out); + return out.toString(); + } + + // ---------- Rendering ---------- + + private void render(Node node, int level, int currentLinePos, StringBuilder out) { + if (node instanceof PrimitiveNode) { + String text = ((PrimitiveNode) node).raw; + out.append(text); + return; + } + if (node instanceof ObjectNode) { + ObjectNode obj = (ObjectNode) node; + if (obj.fields.isEmpty()) { + out.append("{}"); + return; + } + String inline = tryInlineObject(obj, level, currentLinePos); + if (inline != null) { + out.append(inline); + return; + } + out.append("{\n"); + for (int i = 0; i < obj.fields.size(); i++) { + Pair field = obj.fields.get(i); + indent(out, level + 1); + out.append(field.key.raw); + out.append(": "); + render(field.value, level + 1, (level + 1) * indentSize + field.key.raw.length() + 2, out); + if (i < obj.fields.size() - 1) + out.append(','); + out.append('\n'); + } + indent(out, level); + out.append('}'); + return; + } + if (node instanceof ArrayNode) { + ArrayNode arr = (ArrayNode) node; + if (arr.values.isEmpty()) { + out.append("[]"); + return; + } + String inline = tryInlineArray(arr, level, currentLinePos); + if (inline != null) { + out.append(inline); + return; + } + // NEW: if array is all primitives, pack multiple per line up to maxWidth. + if (isAllPrimitives(arr)) { + renderArrayWrappedPrimitives(arr, level, out); + return; + } + // Fallback: one element per line (recursively formatted) + out.append("[\n"); + for (int i = 0; i < arr.values.size(); i++) { + Node v = arr.values.get(i); + indent(out, level + 1); + render(v, level + 1, (level + 1) * indentSize, out); + if (i < arr.values.size() - 1) + out.append(','); + out.append('\n'); + } + indent(out, level); + out.append(']'); + } + } + + /** + * Pack primitive array elements into rows, wrapping before exceeding maxWidth. + */ + private void renderArrayWrappedPrimitives(ArrayNode arr, int level, StringBuilder out) { + out.append("[\n"); + int indentLen = (level + 1) * indentSize; + int linePos = indentLen; + indent(out, level + 1); + for (int i = 0; i < arr.values.size(); i++) { + String s = ((PrimitiveNode) arr.values.get(i)).raw; + String sep = (i < arr.values.size() - 1) ? ", " : ""; + int tokenLen = s.length() + sep.length(); + // If this token doesn't fit and we already have something on the line, wrap. + if (linePos > indentLen && linePos + tokenLen > maxWidth) { + out.append('\n'); + indent(out, level + 1); + linePos = indentLen; + } + out.append(s); + if (!sep.isEmpty()) + out.append(sep); + linePos += tokenLen; + } + out.append('\n'); + indent(out, level); + out.append(']'); + } + + private boolean isAllPrimitives(ArrayNode arr) { + for (Node v : arr.values) { + if (!(v instanceof PrimitiveNode)) + return false; + } + return true; + } + + private String tryInlineObject(ObjectNode obj, int level, int currentLinePos) { + if (obj.fields.size() > inlineMaxElements) + return null; + for (Pair f : obj.fields) { + if (!(f.value instanceof PrimitiveNode)) + return null; + } + String inline = buildInlineObject(obj); + int budget = maxWidth - currentLinePos; + return inline.length() <= Math.max(0, budget) ? inline : null; + } + + private String buildInlineObject(ObjectNode obj) { + StringBuilder sb = new StringBuilder(16 * obj.fields.size() + 2); + sb.append('{'); + for (int i = 0; i < obj.fields.size(); i++) { + Pair f = obj.fields.get(i); + sb.append(f.key.raw).append(": ").append(((PrimitiveNode) f.value).raw); + if (i < obj.fields.size() - 1) + sb.append(", "); + } + sb.append('}'); + return sb.toString(); + } + + private String tryInlineArray(ArrayNode arr, int level, int currentLinePos) { + if (arr.values.size() > inlineMaxElements) + return null; + for (Node v : arr.values) { + if (!(v instanceof PrimitiveNode)) + return null; + } + String inline = buildInlineArray(arr); + int budget = maxWidth - currentLinePos; + return inline.length() <= Math.max(0, budget) ? inline : null; + } + + private String buildInlineArray(ArrayNode arr) { + StringBuilder sb = new StringBuilder(16 * arr.values.size() + 2); + sb.append('['); + for (int i = 0; i < arr.values.size(); i++) { + sb.append(((PrimitiveNode) arr.values.get(i)).raw); + if (i < arr.values.size() - 1) + sb.append(", "); + } + sb.append(']'); + return sb.toString(); + } + + private void indent(StringBuilder out, int level) { + int n = level * indentSize; + for (int i = 0; i < n; i++) + out.append(' '); + } + + // ---------- AST Nodes ---------- + + private interface Node { + } + + private static class PrimitiveNode implements Node { + final String raw; // includes quotes for strings + + PrimitiveNode(String raw) { + this.raw = raw; + } + } + + private static class ObjectNode implements Node { + final List fields = new ArrayList<>(); + } + + private static class ArrayNode implements Node { + final List values = new ArrayList<>(); + } + + private static class Pair { + final PrimitiveNode key; // must be a string token (with quotes) + final Node value; + + Pair(PrimitiveNode key, Node value) { + this.key = key; + this.value = value; + } + } + + // ---------- Parser ---------- + + private static class Parser { + private final Tokenizer tz; + private Token look; + + Parser(Tokenizer tz) { + this.tz = tz; + this.look = tz.next(); + } + + Node parse() { + Node v = parseValue(); + expect(TokenType.EOF); + return v; + } + + private Node parseValue() { + switch (look.type) { + case LBRACE: + return parseObject(); + case LBRACKET: + return parseArray(); + case STRING: + case NUMBER: + case TRUE: + case FALSE: + case NULL: + String raw = look.text; + consume(); + return new PrimitiveNode(raw); + default: + throw error("Expected value"); + } + } + + private ObjectNode parseObject() { + expect(TokenType.LBRACE); + ObjectNode obj = new ObjectNode(); + if (look.type == TokenType.RBRACE) { + consume(); // empty {} + return obj; + } + while (true) { + if (look.type != TokenType.STRING) + throw error("Object key must be a string"); + PrimitiveNode key = new PrimitiveNode(look.text); + consume(); + expect(TokenType.COLON); + Node value = parseValue(); + obj.fields.add(new Pair(key, value)); + if (look.type == TokenType.COMMA) { + consume(); + } else if (look.type == TokenType.RBRACE) { + consume(); + break; + } else { + throw error("Expected ',' or '}' in object"); + } + } + return obj; + } + + private ArrayNode parseArray() { + expect(TokenType.LBRACKET); + ArrayNode arr = new ArrayNode(); + if (look.type == TokenType.RBRACKET) { + consume(); // empty [] + return arr; + } + while (true) { + arr.values.add(parseValue()); + if (look.type == TokenType.COMMA) { + consume(); + } else if (look.type == TokenType.RBRACKET) { + consume(); + break; + } else { + throw error("Expected ',' or ']' in array"); + } + } + return arr; + } + + private void expect(TokenType t) { + if (look.type != t) + throw error("Expected " + t + " but found " + look.type); + consume(); + } + + private void consume() { + look = tz.next(); + } + + private IllegalArgumentException error(String msg) { + return new IllegalArgumentException(msg + " at position " + tz.lastPosition()); + } + } + + // ---------- Tokenizer ---------- + + private enum TokenType { + LBRACE, RBRACE, LBRACKET, RBRACKET, COLON, COMMA, STRING, NUMBER, TRUE, FALSE, NULL, EOF + } + + private static class Token { + final TokenType type; + final String text; // raw lexeme for STRING/NUMBER/TRUE/FALSE/NULL + final int pos; + + Token(TokenType type, String text, int pos) { + this.type = type; + this.text = text; + this.pos = pos; + } + } + + private static class Tokenizer { + private final String s; + private int i = 0; + private int lastPos = 0; + + Tokenizer(String s) { + this.s = s; + } + + int lastPosition() { + return lastPos; + } + + Token next() { + skipWs(); + lastPos = i; + if (i >= s.length()) + return new Token(TokenType.EOF, "", i); + char c = s.charAt(i); + switch (c) { + case '{': + i++; + return new Token(TokenType.LBRACE, "{", lastPos); + case '}': + i++; + return new Token(TokenType.RBRACE, "}", lastPos); + case '[': + i++; + return new Token(TokenType.LBRACKET, "[", lastPos); + case ']': + i++; + return new Token(TokenType.RBRACKET, "]", lastPos); + case ':': + i++; + return new Token(TokenType.COLON, ":", lastPos); + case ',': + i++; + return new Token(TokenType.COMMA, ",", lastPos); + case '"': + return readString(); + default: + if (c == '-' || (c >= '0' && c <= '9')) + return readNumber(); + if (isAlphaStart(c)) + return readKeyword(); + throw new IllegalArgumentException("Unexpected character '" + c + "' at position " + i); + } + } + + private void skipWs() { + while (i < s.length()) { + char c = s.charAt(i); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + i++; + continue; + } + break; + } + } + + private Token readString() { + int start = i; + StringBuilder sb = new StringBuilder(); + sb.append('"'); + i++; // skip opening quote + boolean escaped = false; + while (i < s.length()) { + char c = s.charAt(i++); + sb.append(c); + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + // end of string + return new Token(TokenType.STRING, sb.toString(), start); + } + } + throw new IllegalArgumentException("Unterminated string starting at " + start); + } + + private Token readNumber() { + int start = i; + if (s.charAt(i) == '-') + i++; + if (i >= s.length()) + throw new IllegalArgumentException("Invalid number at end"); + if (s.charAt(i) == '0') { + i++; + } else if (isDigit(s.charAt(i))) { + while (i < s.length() && isDigit(s.charAt(i))) + i++; + } else { + throw new IllegalArgumentException("Invalid number at " + start); + } + if (i < s.length() && s.charAt(i) == '.') { + i++; + if (i >= s.length() || !isDigit(s.charAt(i))) + throw new IllegalArgumentException("Invalid fraction at " + start); + while (i < s.length() && isDigit(s.charAt(i))) + i++; + } + if (i < s.length() && (s.charAt(i) == 'e' || s.charAt(i) == 'E')) { + i++; + if (i < s.length() && (s.charAt(i) == '+' || s.charAt(i) == '-')) + i++; + if (i >= s.length() || !isDigit(s.charAt(i))) + throw new IllegalArgumentException("Invalid exponent at " + start); + while (i < s.length() && isDigit(s.charAt(i))) + i++; + } + return new Token(TokenType.NUMBER, s.substring(start, i), start); + } + + private Token readKeyword() { + int start = i; + while (i < s.length() && isAlpha(s.charAt(i))) + i++; + String kw = s.substring(start, i); + switch (kw) { + case "true": + return new Token(TokenType.TRUE, "true", start); + case "false": + return new Token(TokenType.FALSE, "false", start); + case "null": + return new Token(TokenType.NULL, "null", start); + default: + throw new IllegalArgumentException("Unexpected token '" + kw + "' at " + start); + } + } + + private boolean isAlphaStart(char c) { + return (c >= 'a' && c <= 'z'); // JSON literals are lowercase + } + + private boolean isAlpha(char c) { + return (c >= 'a' && c <= 'z'); + } + + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + } + + // ---------- Simple demo (optional) ---------- + public static void main(String[] args) { + String sample = "{\"a\":1,\"b\":[1,2,3,4,5,6,7,8,9,10],\"c\":[\"a very long string that will likely force wrapping\",2,3],\"d\":[{\"x\":1},{\"y\":2}],\"e\":[[1,2],[3,4]]}"; + System.out.println(SmartJSONFormatter.format(sample)); + } +} -- cgit v1.2.3