From bebfd62a0a148da20ea2316b823fb6357146a7cb Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 Jul 2026 00:54:27 +0200 Subject: [PATCH] Show the hand cursor when hovering links in the RichTextArea renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RichTextArea gives every text segment the default I-beam cursor, so links were indistinguishable from ordinary selectable text on hover — unlike FxRenderer, whose link runs already carry Cursor.HAND. The control has no built-in link support, but its rendering is driven by an overridable StyleHandlerRegistry. HtmlRichTextArea overrides getStyleHandlerRegistry() to extend the control's default registry with a segment handler for the custom HREF attribute that appends `-fx-cursor: hand` through the CellContext — the same mechanism the built-in bold/italic/color handlers use. Because the skin replaces each segment node's inline style on every layout, a recycled node reused for a non-link run does not keep a stale hand cursor. Wired into RichTextRenderer.render and RichHtmlView; covered by a gui-tagged test that drives the same StyleHandlerRegistry.process path the skin uses. Fixes #5 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../htmltonode/rich/HtmlRichTextArea.java | 48 ++++++++ .../jabref/htmltonode/rich/RichHtmlView.java | 2 +- .../htmltonode/rich/RichTextRenderer.java | 2 +- .../htmltonode/HtmlRichTextAreaTest.java | 107 ++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java create mode 100644 src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java diff --git a/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java b/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java new file mode 100644 index 0000000..2fbed1f --- /dev/null +++ b/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java @@ -0,0 +1,48 @@ +package org.jabref.htmltonode.rich; + +import jfx.incubator.scene.control.richtext.RichTextArea; +import jfx.incubator.scene.control.richtext.StyleHandlerRegistry; +import jfx.incubator.scene.control.richtext.model.StyledTextModel; +import jfx.incubator.scene.control.richtext.skin.CellContext; + +/// A [RichTextArea] that renders links with the hand cursor. +/// +/// The incubator control gives every segment's `Text` node the default text (I-beam) cursor, so a +/// link would otherwise be indistinguishable from ordinary selectable text on hover — unlike +/// [org.jabref.htmltonode.FxRenderer], whose link runs carry [javafx.scene.Cursor#HAND]. The +/// control has no built-in notion of links; the recommended way to react to a custom character +/// attribute is to extend its [StyleHandlerRegistry] (see the incubator docs). This subclass adds a +/// segment handler for [RichTextRenderer#HREF] that appends an `-fx-cursor: hand` inline style via +/// the [CellContext], exactly as the control's own attribute handlers do. Routing it through the +/// cell context (rather than setting the cursor on the node directly) means the skin re-applies it +/// on every layout and, because it replaces a segment node's whole inline style, a recycled node +/// reused for a non-link run does not keep a stale hand cursor. +public final class HtmlRichTextArea extends RichTextArea { + + /// Mirrors the `-fx-...:value;` shape the control's built-in handlers append to the cell style. + private static final String HAND_CURSOR_STYLE = "-fx-cursor:hand;"; + + private static final StyleHandlerRegistry REGISTRY = buildRegistry(); + + public HtmlRichTextArea() { + super(); + } + + public HtmlRichTextArea(StyledTextModel model) { + super(model); + } + + @Override + public StyleHandlerRegistry getStyleHandlerRegistry() { + return REGISTRY; + } + + private static StyleHandlerRegistry buildRegistry() { + // builder(parent) seeds the new registry with all of the control's default handlers, so + // bold/italic/color/… keep working; we only add link-cursor handling on top. + StyleHandlerRegistry.Builder builder = StyleHandlerRegistry.builder(RichTextArea.styleHandlerRegistry); + builder.setSegHandler(RichTextRenderer.HREF, + (RichTextArea control, CellContext context, String href) -> context.addStyle(HAND_CURSOR_STYLE)); + return builder.build(); + } +} diff --git a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java index 241b4e9..aad5fbe 100644 --- a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java +++ b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java @@ -26,7 +26,7 @@ /// JavaFX application thread. Requires the `jfx.incubator.richtext` module at runtime. public class RichHtmlView extends StackPane { - private final RichTextArea area = new RichTextArea(); + private final RichTextArea area = new HtmlRichTextArea(); private final StringProperty html = new SimpleStringProperty(this, "html", ""); private final ObjectProperty options = new SimpleObjectProperty<>(this, "options", HtmlRenderOptions.defaults()); diff --git a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java index 9cac404..03328fc 100644 --- a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java +++ b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java @@ -79,7 +79,7 @@ public static StyledTextModel buildModel(List blocks, HtmlRenderOptions o /// @param options rendering options /// @return the configured control public static RichTextArea render(List blocks, HtmlRenderOptions options) { - RichTextArea area = new RichTextArea(buildModel(blocks, options)); + RichTextArea area = new HtmlRichTextArea(buildModel(blocks, options)); configure(area, options); return area; } diff --git a/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java b/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java new file mode 100644 index 0000000..b992bc1 --- /dev/null +++ b/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java @@ -0,0 +1,107 @@ +package org.jabref.htmltonode; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.text.Text; + +import org.jabref.htmltonode.rich.HtmlRichTextArea; +import org.jabref.htmltonode.rich.RichTextRenderer; + +import jfx.incubator.scene.control.richtext.model.StyleAttributeMap; +import jfx.incubator.scene.control.richtext.skin.CellContext; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Verifies that [HtmlRichTextArea] gives link runs the hand cursor. Constructing the incubator +/// control needs the JavaFX toolkit, so this is a `gui` test — run via `./gradlew guiTest` with a +/// display or under `xvfb-run`. +@Tag("gui") +class HtmlRichTextAreaTest { + + @BeforeAll + static void startToolkit() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + try { + Platform.startup(started::countDown); + } catch (IllegalStateException alreadyRunning) { + started.countDown(); + } + assertTrue(started.await(15, TimeUnit.SECONDS), "JavaFX toolkit failed to start"); + } + + /// Captures the inline style the skin's cell context would apply to a segment node — the same + /// path [jfx.incubator.scene.control.richtext.StyleHandlerRegistry#process] drives per attribute. + private static final class CapturingCellContext implements CellContext { + private final Node node; + private final StringBuilder style = new StringBuilder(); + + CapturingCellContext(Node node) { + this.node = node; + } + + @Override + public void addStyle(String s) { + style.append(s); + } + + @Override + public Node getNode() { + return node; + } + + @Override + public StyleAttributeMap getAttributes() { + return StyleAttributeMap.builder().build(); + } + + String style() { + return style.toString(); + } + } + + @Test + void linkRunGetsHandCursorWhileInheritedHandlersStillApply() throws Exception { + AtomicReference error = new AtomicReference<>(); + AtomicReference linkStyle = new AtomicReference<>(); + AtomicReference boldStyle = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + Platform.runLater(() -> { + try { + HtmlRichTextArea area = new HtmlRichTextArea(); + + CapturingCellContext link = new CapturingCellContext(new Text("10.1/x")); + area.getStyleHandlerRegistry() + .process(area, false, link, RichTextRenderer.HREF, "https://doi.org/10.1/x"); + linkStyle.set(link.style()); + + // The default handlers (here: bold) must survive the registry rebuild. + CapturingCellContext bold = new CapturingCellContext(new Text("bold")); + area.getStyleHandlerRegistry() + .process(area, false, bold, StyleAttributeMap.BOLD, Boolean.TRUE); + boldStyle.set(bold.style()); + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + assertTrue(done.await(15, TimeUnit.SECONDS), "FX task timed out"); + if (error.get() != null) { + throw new AssertionError("FX task failed", error.get()); + } + assertTrue(linkStyle.get().contains("-fx-cursor:hand"), + "link run should request the hand cursor, was: " + linkStyle.get()); + assertFalse(boldStyle.get().isEmpty(), "inherited bold handler should still apply a style"); + assertFalse(boldStyle.get().contains("-fx-cursor"), "non-link run must not get the hand cursor"); + } +}