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" ); */