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 "