diff --git a/connector/src/main/java/devtoolsfx/connector/BoundsPane.java b/connector/src/main/java/devtoolsfx/connector/BoundsPane.java index 0a94883..9e34f51 100644 --- a/connector/src/main/java/devtoolsfx/connector/BoundsPane.java +++ b/connector/src/main/java/devtoolsfx/connector/BoundsPane.java @@ -1,6 +1,8 @@ package devtoolsfx.connector; import devtoolsfx.util.SceneUtils; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; import javafx.geometry.BoundingBox; import javafx.geometry.Bounds; import javafx.geometry.Point2D; @@ -9,13 +11,13 @@ import javafx.scene.paint.Color; import javafx.scene.shape.Line; import javafx.scene.shape.Rectangle; +import javafx.scene.shape.Shape; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; import javafx.scene.shape.StrokeType; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import static java.lang.System.Logger; -import static java.lang.System.Logger.Level; - /** * Contains the logic to implement highlighting of arbitrary nodes within the given parent node. * See {@link #attach(Parent)}. There are three types of nodes: a colored rectangle to @@ -33,7 +35,7 @@ final class BoundsPane { private @Nullable Parent parent; public BoundsPane() { - // pass + updateHighlight(Highlight.defaults()); } /** @@ -180,6 +182,18 @@ public void toggleBaselineDisplay(@Nullable Node target) { } } + /** + * Applies the given {@link Highlight} to the visual elements, updating + * the layout bounds, bounds-in-parent, and baseline highlights accordingly. + * + * @param highlight the {@link Highlight} object containing the visual parameters to apply + */ + public void updateHighlight(Highlight highlight) { + updateBoundsHighlight(highlight.getLayoutBounds(), layoutBoundsRect); + updateBoundsHighlight(highlight.getBoundsInParent(), boundsInParentRect); + updateBaselineHighlight(highlight.getBaseline(), baselineStroke); + } + /////////////////////////////////////////////////////////////////////////// /** @@ -189,12 +203,6 @@ public void toggleBaselineDisplay(@Nullable Node target) { private Rectangle createBoundsInParentRect() { var r = new Rectangle(); r.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "layoutBoundsRect"); - r.setFill(null); - r.setStroke(Color.GREEN); - r.setStrokeType(StrokeType.INSIDE); - r.setOpacity(0.8); - r.getStrokeDashArray().addAll(3.0, 3.0); - r.setStrokeWidth(1); r.setManaged(false); r.setMouseTransparent(true); return r; @@ -207,8 +215,6 @@ private Rectangle createBoundsInParentRect() { private Rectangle createLayoutBoundsRect() { var r = new Rectangle(); r.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "boundsInParentRect"); - r.setFill(Color.YELLOW); - r.setOpacity(0.5); r.setManaged(false); r.setMouseTransparent(true); return r; @@ -221,13 +227,46 @@ private Rectangle createLayoutBoundsRect() { private Line createBaselineStroke() { var l = new Line(); l.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "baselineLine"); - l.setStroke(Color.RED); - l.setOpacity(.75); - l.setStrokeWidth(1); l.setManaged(false); return l; } + private void updateBoundsHighlight(Highlight.BoundsHighlight h, Rectangle rect) { + if (h.getFill() != null) { + rect.setFill(Color.web(h.getFill())); + } else { + rect.setFill(null); + } + + if (h.getStroke() != null) { + rect.setStroke(Color.web(h.getStroke())); + } else { + rect.setStroke(null); + } + updateShapeHighlight(h, rect); + } + + private void updateBaselineHighlight(Highlight.BaselineHighlight h, Line line) { + line.setStroke(Color.web(h.getStroke())); + updateShapeHighlight(h, line); + } + + private void updateShapeHighlight(Highlight.AbstractHighlight h, Shape shape) { + shape.setStrokeType(StrokeType.valueOf(h.getStrokeType())); + + shape.getStrokeDashArray().clear(); + for (var d : h.getStrokeDashArray()) { + shape.getStrokeDashArray().add(d); + } + shape.setStrokeDashOffset(h.getStrokeDashOffset()); + shape.setStrokeLineCap(StrokeLineCap.valueOf(h.getStrokeLineCap())); + shape.setStrokeLineJoin(StrokeLineJoin.valueOf(h.getStrokeLineJoin())); + shape.setStrokeMiterLimit(h.getStrokeMiterLimit()); + + shape.setStrokeWidth(h.getStrokeWidth()); + shape.setOpacity(h.getOpacity()); + } + /** * Hides the given line. */ diff --git a/connector/src/main/java/devtoolsfx/connector/Connector.java b/connector/src/main/java/devtoolsfx/connector/Connector.java index 7ba81c0..1bc3ecc 100644 --- a/connector/src/main/java/devtoolsfx/connector/Connector.java +++ b/connector/src/main/java/devtoolsfx/connector/Connector.java @@ -5,6 +5,8 @@ import devtoolsfx.scenegraph.Element; import devtoolsfx.scenegraph.WindowProperties; import devtoolsfx.scenegraph.attributes.AttributeCategory; +import java.util.List; +import java.util.Map; import javafx.application.Application; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.scene.Parent; @@ -12,9 +14,6 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import java.util.List; -import java.util.Map; - /** * The connector serves as the main entry point for application monitoring. It accepts * the target app's primary stage and tracks and reports its state and changes via the @@ -72,6 +71,13 @@ public interface Connector { */ void selectNode(int uid, Element element, @Nullable HighlightOptions opts); + /** + * Selects and starts monitoring the node attributes and visual highlights of the + * specified element's bounds, if possible. This method is mutually exclusive with + * {@link #selectWindow(int)}. + */ + void selectNode(int uid, Element element, @Nullable HighlightOptions opts, @Nullable Highlight highlight); + /** * The opposite of {@link #selectNode(int, Element, HighlightOptions)}. * diff --git a/connector/src/main/java/devtoolsfx/connector/Highlight.java b/connector/src/main/java/devtoolsfx/connector/Highlight.java new file mode 100644 index 0000000..802bec7 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/Highlight.java @@ -0,0 +1,277 @@ +package devtoolsfx.connector; + +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Contains the highlighting visual parameters. + */ +@NullMarked +public final class Highlight { + + public static abstract class AbstractHighlight { + private final String stroke; + private final String strokeType; + private final List strokeDashArray; + private final double strokeDashOffset; + private final String strokeLineCap; + private final String strokeLineJoin; + private final double strokeMiterLimit; + private final double strokeWidth; + private final double opacity; + + private AbstractHighlight(@Nullable String stroke, String strokeType, List strokeDashArray, + double strokeDashOffset, String strokeLineCap, String strokeLineJoin, double strokeMiterLimit, + double strokeWidth, double opacity) { + this.stroke = stroke; + this.strokeType = strokeType; + this.strokeDashArray = strokeDashArray; + this.strokeDashOffset = strokeDashOffset; + this.strokeLineCap = strokeLineCap; + this.strokeLineJoin = strokeLineJoin; + this.strokeMiterLimit = strokeMiterLimit; + this.strokeWidth = strokeWidth; + this.opacity = opacity; + } + + public String getStroke() { + return stroke; + } + + public String getStrokeType() { + return strokeType; + } + + public List getStrokeDashArray() { + return strokeDashArray; + } + + public double getStrokeDashOffset() { + return strokeDashOffset; + } + + public String getStrokeLineCap() { + return strokeLineCap; + } + + public String getStrokeLineJoin() { + return strokeLineJoin; + } + + public double getStrokeMiterLimit() { + return strokeMiterLimit; + } + + public double getStrokeWidth() { + return strokeWidth; + } + + public double getOpacity() { + return opacity; + } + + protected String fieldsToString() { + return "stroke=" + stroke + + ", strokeType=" + strokeType + + ", strokeDashArray=" + strokeDashArray + + ", strokeDashOffset=" + strokeDashOffset + + ", strokeLineCap=" + strokeLineCap + + ", strokeLineJoin=" + strokeLineJoin + + ", strokeMiterLimit=" + strokeMiterLimit + + ", strokeWidth=" + strokeWidth + + ", opacity=" + opacity; + } + } + + public static abstract class AbstractBuilder> { + protected String stroke; + protected String strokeType = "CENTERED"; + protected List strokeDashArray = Collections.emptyList(); + protected double strokeDashOffset = 0.0; + protected String strokeLineCap = "SQUARE"; + protected String strokeLineJoin = "MITER"; + protected double strokeMiterLimit = 10.0; + protected double strokeWidth = 1.0; + protected double opacity = 1.0; + + private AbstractBuilder() { + + } + + public T stroke(@Nullable String stroke) { + this.stroke = stroke; + return (T) this; + } + + public T strokeType(String strokeType) { + this.strokeType = strokeType; + return (T) this; + } + + public T strokeDashArray(List strokeDashArray) { + this.strokeDashArray = List.copyOf(strokeDashArray); + return (T) this; + } + + public T strokeDashOffset(double strokeDashOffset) { + this.strokeDashOffset = strokeDashOffset; + return (T) this; + } + + public T strokeLineCap(String strokeLineCap) { + this.strokeLineCap = strokeLineCap; + return (T) this; + } + + public T strokeLineJoin(String strokeLineJoin) { + this.strokeLineJoin = strokeLineJoin; + return (T) this; + } + + public T strokeMiterLimit(double strokeMiterLimit) { + this.strokeMiterLimit = strokeMiterLimit; + return (T) this; + } + + public T strokeWidth(double strokeWidth) { + this.strokeWidth = strokeWidth; + return (T) this; + } + + public T opacity(double opacity) { + this.opacity = opacity; + return (T) this; + } + + public abstract AbstractHighlight build(); + } + + public static final class BoundsHighlight extends AbstractHighlight { + + public static BoundsBuilder builder() { + return new BoundsBuilder(); + } + + private final String fill; + + private BoundsHighlight(@Nullable String fill, @Nullable String stroke, String strokeType, + List strokeDashArray, double strokeDashOffset, String strokeLineCap, String strokeLineJoin, + double strokeMiterLimit, double strokeWidth, double opacity) { + super(stroke, strokeType, strokeDashArray, strokeDashOffset, strokeLineCap, strokeLineJoin, + strokeMiterLimit, strokeWidth, opacity); + this.fill = fill; + } + + public String getFill() { + return fill; + } + + @Override + public String toString() { + return "BoundsHighlight{fill=" + fill + ", " + fieldsToString() + "}"; + } + } + + public static final class BoundsBuilder extends AbstractBuilder { + private String fill; + + private BoundsBuilder() { + // empty + } + + public BoundsBuilder fill(@Nullable String fill) { + this.fill = fill; + return this; + } + + @Override + public BoundsHighlight build() { + return new BoundsHighlight(fill, stroke, strokeType, strokeDashArray, strokeDashOffset, strokeLineCap, + strokeLineJoin, strokeMiterLimit, strokeWidth, opacity); + } + } + + public static final class BaselineHighlight extends AbstractHighlight { + + public static BaselineBuilder builder() { + return new BaselineBuilder(); + } + + private BaselineHighlight(String stroke, String strokeType, List strokeDashArray, + double strokeDashOffset, String strokeLineCap, String strokeLineJoin, + double strokeMiterLimit, double strokeWidth, double opacity) { + super(stroke, strokeType, strokeDashArray, strokeDashOffset, strokeLineCap, strokeLineJoin, + strokeMiterLimit, strokeWidth, opacity); + } + + @Override + public String toString() { + return "BaselineHighlight{" + fieldsToString() + "}"; + } + } + + public static final class BaselineBuilder extends AbstractBuilder { + + private BaselineBuilder() { + // empty + } + + @Override + public BaselineHighlight build() { + if (stroke == null) { + throw new IllegalStateException("Stroke must be set for BaselineHighlight"); + } + return new BaselineHighlight(stroke, strokeType, strokeDashArray, strokeDashOffset, strokeLineCap, + strokeLineJoin, strokeMiterLimit, strokeWidth, opacity); + } + + @Override + public BaselineBuilder stroke(String stroke) { // not nullable + this.stroke = stroke; + return this; + } + } + + public static Highlight defaults() { + var layoutBounds = BoundsHighlight.builder() + .fill("#FFFF00") + .opacity(0.5) + .build(); + var boundsInParent = BoundsHighlight.builder() + .stroke("#00FF00") + .strokeType("INSIDE") + .opacity(0.8) + .strokeDashArray(List.of(3.0, 3.0)) + .build(); + + var baseline = BaselineHighlight.builder() + .stroke("#FF0000") + .opacity(0.75) + .build(); + return new Highlight(layoutBounds, boundsInParent, baseline); + } + + private final BoundsHighlight layoutBounds; + private final BoundsHighlight boundsInParent; + private final BaselineHighlight baseline; + + public Highlight(BoundsHighlight layoutBounds, BoundsHighlight boundsInParent, BaselineHighlight baseline) { + this.layoutBounds = layoutBounds; + this.boundsInParent = boundsInParent; + this.baseline = baseline; + } + + public BoundsHighlight getLayoutBounds() { + return layoutBounds; + } + + public BoundsHighlight getBoundsInParent() { + return boundsInParent; + } + + public BaselineHighlight getBaseline() { + return baseline; + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/LocalConnector.java b/connector/src/main/java/devtoolsfx/connector/LocalConnector.java index 466cf51..4f8b346 100644 --- a/connector/src/main/java/devtoolsfx/connector/LocalConnector.java +++ b/connector/src/main/java/devtoolsfx/connector/LocalConnector.java @@ -8,6 +8,12 @@ import devtoolsfx.scenegraph.WindowProperties; import devtoolsfx.scenegraph.attributes.AttributeCategory; import devtoolsfx.util.SceneUtils; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; import javafx.application.Application; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; @@ -19,13 +25,6 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import java.lang.System.Logger; -import java.lang.System.Logger.Level; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.*; - /** * Implements the {@link Connector} interface for local (this JVM process) nodes. * Also see {@link LocalElement}. @@ -140,12 +139,17 @@ public void selectWindow(int uid) { } @Override - public void selectNode(int uid, Element element, @Nullable HighlightOptions opts) { + public void selectNode(int uid, Element element, HighlightOptions opts) { + selectNode(uid, element, opts, null); + } + + @Override + public void selectNode(int uid, Element element, @Nullable HighlightOptions opts, @Nullable Highlight highlight) { var monitor = monitors.get(uid); if (monitor != null && element.isNodeElement()) { var node = element instanceof LocalElement local ? local.unwrap() : monitor.findNode(element.getUID()); if (node != null) { - monitor.selectNode(node, Objects.requireNonNullElse(opts, HighlightOptions.defaults())); + monitor.selectNode(node, Objects.requireNonNullElse(opts, HighlightOptions.defaults()), highlight); } else { LOGGER.log(Level.WARNING, "Unable to select element: unknown node"); } diff --git a/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java b/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java index ee3cd95..4290409 100644 --- a/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java +++ b/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java @@ -5,6 +5,7 @@ import devtoolsfx.scenegraph.WindowProperties; import devtoolsfx.scenegraph.attributes.AttributeCategory; import devtoolsfx.util.SceneUtils; +import java.util.*; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.Property; @@ -23,8 +24,6 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import java.util.*; - @NullMarked final class WindowMonitor { @@ -140,7 +139,7 @@ public void selectWindow() { /** * See {@link Connector#selectNode(int, Element, HighlightOptions)}. */ - public void selectNode(@Nullable Node node, @Nullable HighlightOptions opts) { + public void selectNode(@Nullable Node node, @Nullable HighlightOptions opts, @Nullable Highlight highlight) { Node prevNode = selectedNode; if (prevNode != null) { prevNode.boundsInParentProperty().removeListener(selectedNodeBoundsListener); @@ -158,6 +157,9 @@ public void selectNode(@Nullable Node node, @Nullable HighlightOptions opts) { selectedNode.boundsInParentProperty().addListener(selectedNodeBoundsListener); selectedNode.layoutBoundsProperty().addListener(selectedNodeBoundsListener); + if (highlight != null) { + boundsPane.updateHighlight(highlight); + } boundsPane.toggleLayoutBoundsDisplay(highlightOpts.showLayoutBounds() ? selectedNode : null); boundsPane.toggleBoundsInParentDisplay(highlightOpts.showBoundsInParent() ? selectedNode : null); boundsPane.toggleBaselineDisplay(highlightOpts.showBaseline() ? selectedNode : null);