diff options
| author | Benjamin Culkin <scorpress@gmail.com> | 2026-01-28 21:36:12 -0500 |
|---|---|---|
| committer | Benjamin Culkin <scorpress@gmail.com> | 2026-01-28 21:36:12 -0500 |
| commit | eef6e132080c5e46ba8c47ecfaca83fa8e0e214e (patch) | |
| tree | b99daffa062652d061c883382ce9068e735dae7b | |
| parent | 8f0b451c442985d7338bd1d09bc5798220787cb8 (diff) | |
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
6 files changed, 3244 insertions, 0 deletions
diff --git a/base/src/examples/java/bjc/utils/examples/gui/MarkdownEditorKitDemo.java b/base/src/examples/java/bjc/utils/examples/gui/MarkdownEditorKitDemo.java new file mode 100644 index 0000000..e48c939 --- /dev/null +++ b/base/src/examples/java/bjc/utils/examples/gui/MarkdownEditorKitDemo.java @@ -0,0 +1,46 @@ +package bjc.utils.examples.gui; + +import javax.swing.JFrame; +import javax.swing.JScrollPane; +import javax.swing.JTextPane; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; + +import bjc.utils.gui.MarkdownEditorKit; +import bjc.utils.gui.MarkdownEditorKit.MarkdownDocument; + +public class MarkdownEditorKitDemo { + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + String md = """ + # Table demo + + Here is a table: + + | Tool | Notes | + |------|------| + | *web.run* | Search the web | + | python | Execute code | + + And this form too: + + Tool | Notes + ---- | ----- + A | B + """; + + JTextPane pane = new JTextPane(); + pane.setEditable(false); + pane.setEditorKit(new MarkdownEditorKit()); + ((MarkdownDocument) pane.getDocument()).setMarkdown(md); + + JFrame f = new JFrame("MarkdownEditorKit Demo"); + f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + f.add(new JScrollPane(pane)); + f.setSize(700, 500); + f.setLocationRelativeTo(null); + f.setVisible(true); + }); + } +} 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<List<String>> rows = new ArrayList<>();
+ rows.add(th.headerCells);
+
+ int r = li + 2;
+ while (r < lines.length && !isBlank(lines[r])) {
+ List<String> 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<List<String>> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> headerCells;
+ final int colCount;
+
+ TableHeader(List<String> headerCells, int colCount) {
+ this.headerCells = headerCells;
+ this.colCount = colCount;
+ }
+
+ static TableHeader tryParse(String headerLine, String dividerLine) {
+ List<String> header = splitPipeRow(headerLine);
+ if (header == null || header.isEmpty())
+ return null;
+
+ List<String> 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 <br> for multi-line definitions
+ escaped = escaped.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>");
+ return "<html><div style='width:320px;'>" + escaped + "</div></html>";
+ }
+
+ 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<Pair> fields = new ArrayList<>();
+ }
+
+ private static class ArrayNode implements Node {
+ final List<Node> 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));
+ }
+}
|
