From eef6e132080c5e46ba8c47ecfaca83fa8e0e214e Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Wed, 28 Jan 2026 21:36:12 -0500 Subject: Add various text UI components This adds a variety of text UI components, namely two suites: * One that is geared towards JSON * One that is geared towards Markdown Details to (perhaps) follow later --- .../java/bjc/utils/gui/MarkdownLinkSupport.java | 374 +++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java (limited to 'base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java') diff --git a/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java new file mode 100644 index 0000000..3a1c72e --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java @@ -0,0 +1,374 @@ +package bjc.utils.gui; + +import javax.swing.*; +import javax.swing.text.*; +import java.awt.*; +import java.awt.event.*; +import java.net.URI; + +public final class MarkdownLinkSupport { + + @FunctionalInterface + public interface LinkHandler { + void onLinkActivated(String href); + } + + /** + * Provides glossary behavior for glossary:// links. + * + * The "key" is whatever you decide (term ID, slug, etc.) extracted from the + * URI. + */ + public interface GlossaryProvider { + /** + * Return a user-facing definition for tooltip display, or null if unknown. You + * can return plain text; it will be wrapped/escaped into HTML. + */ + String getDefinition(String key); + + /** + * Called when user clicks a glossary:// link. + */ + void openEntry(String key); + } + + private MarkdownLinkSupport() { + } + + // --------------------------------------------------------------------- + // Existing behavior (browser links only) + // --------------------------------------------------------------------- + + public static void install(JTextPane pane) { + install(pane, MarkdownLinkSupport::openInBrowserBestEffort, null, "glossary"); + } + + public static void install(JTextPane pane, LinkHandler handler) { + install(pane, handler, null, "glossary"); + } + + // --------------------------------------------------------------------- + // New behavior (browser links + glossary links) + // --------------------------------------------------------------------- + + /** + * Installs link hover/click behavior. + * + * @param pane the JTextPane containing styled text + * @param defaultHandler handler for normal links (http, https, mailto, etc.) + * @param glossary optional glossary provider for glossary-scheme links + * @param glossaryScheme scheme name (e.g. "glossary" => glossary://term) + */ + public static void install(JTextPane pane, LinkHandler defaultHandler, GlossaryProvider glossary, + String glossaryScheme) { + if (pane == null) + throw new IllegalArgumentException("pane == null"); + if (defaultHandler == null) + throw new IllegalArgumentException("defaultHandler == null"); + if (glossaryScheme == null || glossaryScheme.trim().isEmpty()) + glossaryScheme = "glossary"; + + final Cursor defaultCursor = pane.getCursor(); + final Cursor handCursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); + + final String schemeLower = glossaryScheme.toLowerCase(); + + // Simple hover caching so we don't re-set tooltip/cursor every pixel of + // movement + final HoverState hoverState = new HoverState(); + + MouseMotionListener motion = new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + String href = hrefAtPoint(pane, e.getPoint()); + + if (href != null && href.equals(hoverState.lastHref)) { + return; // no change + } + hoverState.lastHref = href; + + if (href == null) { + resetHover(pane, defaultCursor); + return; + } + + if (pane.getCursor() != handCursor) + pane.setCursor(handCursor); + + // Tooltip decision + String tip = tooltipForHref(href, glossary, schemeLower); + pane.setToolTipText(tip); + } + }; + + MouseListener click = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (!SwingUtilities.isLeftMouseButton(e)) + return; + if (e.getClickCount() != 1) + return; + + String href = hrefAtPoint(pane, e.getPoint()); + if (href == null) + return; + + if (isScheme(href, schemeLower) && glossary != null) { + String key = extractGlossaryKey(href, schemeLower); + if (key != null && !key.isEmpty()) { + glossary.openEntry(key); + e.consume(); + return; + } + } + + // default + defaultHandler.onLinkActivated(href); + e.consume(); + } + + @Override + public void mouseExited(MouseEvent e) { + hoverState.lastHref = null; + resetHover(pane, defaultCursor); + } + }; + + pane.addMouseMotionListener(motion); + pane.addMouseListener(click); + } + + private static void resetHover(JTextPane pane, Cursor defaultCursor) { + if (pane.getCursor() != defaultCursor) + pane.setCursor(defaultCursor); + pane.setToolTipText(null); + } + + private static String tooltipForHref(String href, GlossaryProvider glossary, String glossarySchemeLower) { + if (isScheme(href, glossarySchemeLower) && glossary != null) { + String key = extractGlossaryKey(href, glossarySchemeLower); + if (key == null || key.isEmpty()) + return null; + + String def = glossary.getDefinition(key); + if (def == null || def.trim().isEmpty()) { + // Fallback tooltip if you want: show the key + return htmlTooltip("Glossary: " + key); + } + return htmlTooltip(def); + } + + // Default behavior: show the href + return href; + } + + private static boolean isScheme(String href, String schemeLower) { + try { + URI uri = new URI(href); + String scheme = uri.getScheme(); + return scheme != null && schemeLower.equalsIgnoreCase(scheme); + } catch (Exception ignored) { + return false; + } + } + + /** + * Extract your glossary “key” from a glossary:// URI. + * + * Accepts: glossary://TERM glossary://TERM/some/subpath glossary:TERM (also + * works, if you ever emit that) + * + * Returns something stable to use as lookup key. + */ + private static String extractGlossaryKey(String href, String schemeLower) { + try { + URI uri = new URI(href); + String scheme = uri.getScheme(); + if (scheme == null || !schemeLower.equalsIgnoreCase(scheme)) + return null; + + // Preferred: glossary://host/path + String host = uri.getHost(); + String path = uri.getPath(); + + if (host != null && !host.isEmpty()) { + if (path != null && !path.isEmpty() && !"/".equals(path)) { + return host + path; // e.g. TERM/sub + } + return host; // TERM + } + + // Fallback: glossary:TERM or other scheme-specific forms + String ssp = uri.getSchemeSpecificPart(); + if (ssp == null) + return null; + + // strip leading // if present + if (ssp.startsWith("//")) + ssp = ssp.substring(2); + + // strip query/fragment if someone includes them + int q = ssp.indexOf('?'); + if (q >= 0) + ssp = ssp.substring(0, q); + int f = ssp.indexOf('#'); + if (f >= 0) + ssp = ssp.substring(0, f); + + return ssp.trim(); + } catch (Exception ignored) { + // If href isn't a valid URI, you could still support a raw prefix check: + String prefix = schemeLower + "://"; + if (href != null && href.toLowerCase().startsWith(prefix)) { + return href.substring(prefix.length()).trim(); + } + return null; + } + } + + // --------------------------------------------------------------------- + // Link attribute lookup in the StyledDocument + // --------------------------------------------------------------------- + + private static String hrefAtPoint(JTextPane pane, Point p) { + Document doc = pane.getDocument(); + if (!(doc instanceof StyledDocument)) + return null; + + int pos = viewToModelCompat(pane, p); + if (pos < 0 || pos >= doc.getLength()) + return null; + + return findHrefAtOffset((StyledDocument) doc, pos); + } + + private static int viewToModelCompat(JTextPane pane, Point p) { + // Java 8 friendly + try { + return pane.viewToModel(p); + } catch (Exception ignored) { + return -1; + } + } + + private static String findHrefAtOffset(StyledDocument doc, int offset) { + Element charElem = doc.getCharacterElement(offset); + String href = getHrefFromAttributes(charElem.getAttributes()); + if (href != null) + return href; + + // Sometimes attributes land on parent elements + Element parent = charElem.getParentElement(); + while (parent != null) { + href = getHrefFromAttributes(parent.getAttributes()); + if (href != null) + return href; + parent = parent.getParentElement(); + } + return null; + } + + private static String getHrefFromAttributes(AttributeSet attrs) { + Object v = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF); + return (v instanceof String) ? (String) v : null; + } + + // --------------------------------------------------------------------- + // Default “open in browser” handler + // --------------------------------------------------------------------- + + private static void openInBrowserBestEffort(String href) { + if (href == null || href.trim().isEmpty()) + return; + + if (tryBrowse(href)) + return; + + // If missing scheme, try https:// + if (!href.contains("://") && !href.startsWith("mailto:")) { + tryBrowse("https://" + href); + } + } + + private static boolean tryBrowse(String href) { + try { + if (!Desktop.isDesktopSupported()) + return false; + Desktop d = Desktop.getDesktop(); + if (!d.isSupported(Desktop.Action.BROWSE)) + return false; + + d.browse(new URI(href)); + return true; + } catch (Exception ignored) { + return false; + } + } + + // --------------------------------------------------------------------- + // Tooltip formatting + // --------------------------------------------------------------------- + + /** + * Swing tooltips support HTML. This wraps and escapes your glossary text. You + * can tune the width (e.g. 320px) to make definitions readable. + */ + private static String htmlTooltip(String text) { + String escaped = escapeHtml(text); + // Convert newlines to
for multi-line definitions + escaped = escaped.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "
"); + return "
" + escaped + "
"; + } + + private static String escapeHtml(String s) { + if (s == null) + return ""; + StringBuilder sb = new StringBuilder(s.length() + 32); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append("""); + break; + case '\'': + sb.append("'"); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + + private static final class HoverState { + String lastHref; + } +} + +/* + * JTextPane pane = new JTextPane(); pane.setEditorKit(new MarkdownEditorKit()); + * ((MarkdownEditorKit.MarkdownDocument)pane.getDocument()).setMarkdown(markdown + * ); + * + * // Plug in your glossary: MarkdownLinkSupport.install( pane, href -> + * System.out.println("normal link clicked: " + href), // or open in browser new + * MarkdownLinkSupport.GlossaryProvider() { + * + * @Override public String getDefinition(String key) { // Look up from your + * glossary map/DB if ("Stalk".equalsIgnoreCase(key)) { return + * "Stalk: At the beginning of your end step, if an opponent was dealt damage..." + * ; } return null; } + * + * @Override public void openEntry(String key) { // Navigate in your app (select + * glossary panel, jump to entry, etc.) + * System.out.println("open glossary entry: " + key); } }, "glossary" ); + */ \ No newline at end of file -- cgit v1.2.3