Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
2 changes: 1 addition & 1 deletion src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<HtmlRenderOptions> options =
new SimpleObjectProperty<>(this, "options", HtmlRenderOptions.defaults());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public static StyledTextModel buildModel(List<Block> blocks, HtmlRenderOptions o
/// @param options rendering options
/// @return the configured control
public static RichTextArea render(List<Block> blocks, HtmlRenderOptions options) {
RichTextArea area = new RichTextArea(buildModel(blocks, options));
RichTextArea area = new HtmlRichTextArea(buildModel(blocks, options));
configure(area, options);
return area;
}
Expand Down
107 changes: 107 additions & 0 deletions src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java
Original file line number Diff line number Diff line change
@@ -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<Throwable> error = new AtomicReference<>();
AtomicReference<String> linkStyle = new AtomicReference<>();
AtomicReference<String> 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");
}
}