This commit is contained in:
mkpaz 2024-10-10 16:22:56 +04:00
commit e653e8fda1
106 changed files with 12005 additions and 0 deletions

218
.gitignore vendored Executable file
View File

@ -0,0 +1,218 @@
# Created by https://www.toptal.com/developers/gitignore/api/linux,maven,eclipse,windows,intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=linux,maven,eclipse,windows,intellij+all
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
# External tool builders
.externalToolBuilders/
# Locally stored "Eclipse launch configurations"
*.launch
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# CDT-specific (C/C++ Development Tooling)
.cproject
# CDT- autotools
.autotools
# Java annotation processor (APT)
.factorypath
# PDT-specific (PHP Development Tools)
.buildpath
# sbteclipse plugin
.target
# Tern plugin
.tern-project
# TeXlipse plugin
.texlipse
# STS (Spring Tool Suite)
.springBeans
# Code Recommenders
.recommenders/
# Annotation Processing
.apt_generated/
.apt_generated_test/
# Scala IDE specific (Scala & Java development for Eclipse)
.cache-main
.scala_dependencies
.worksheet
# Uncomment this line if you wish to ignore the project description file.
# Typically, this file would be tracked if it contains build/dependency configurations:
#.project
### Eclipse Patch ###
# Spring Boot Tooling
.sts4-cache/
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Maven ###
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
.mvn/wrapper/maven-wrapper.jar
# Eclipse m2e generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/linux,maven,eclipse,windows,intellij+all

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
.screenshots/events.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

BIN
.screenshots/inspector.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

21
LICENSE Executable file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [2022] [mkpaz]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# devtoolsfx
DevToolsFX is a tool for navigating your application's scene graph and exploring node properties. It aims to be similar
to Chrome DevTools, but for JavaFX.
It's lightweight, around 250 KB, with no dependencies, allowing you to easily embed it into your app. The only JavaFX
dependency is `javafx.controls`, which your app will need regardless.
<p align="center">
<img src="https://raw.githubusercontent.com/mkpaz/devtoolsfx/master/.screenshots/inspector.png" alt="inspector"/>
</p>
Find more screenshots [here](https://github.com/mkpaz/devtoolsfx/tree/master/.screenshots).
## Getting started
Maven:
```xml
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx-gui</artifactId>
<version>TBD</version>
</dependency>
```
Gradle:
```groovy
dependencies {
implementation 'io.github.mkpaz:devtoolsfx-gui:TBD'
}
```
After the primary stage is shown, you can launch the dev tools GUI at any time with:
```java
primaryStage.setOnShown(
e -> GUI.openToolStage(primaryStage, getHostServices())
);
```
Check the `devtoolsfx.gui.GUI` class for additional ways to launch the dev tools, such as embedding it at the top or
bottom. Also, refer to the demo for a more detailed example.

42
connector/pom.xml Normal file
View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>devtoolsfx-connector</artifactId>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,89 @@
package devtoolsfx.connector;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import devtoolsfx.scenegraph.attributes.Tracker;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.stream.Collectors;
/**
* Listens for all types of attributes for the given target.
*/
@NullMarked
final class AttributeListener {
private static final Logger LOGGER = System.getLogger(AttributeListener.class.getName());
private final EnumMap<AttributeCategory, Tracker> trackers;
private @Nullable Object target;
public AttributeListener(EventBus eventBus,
EventSource eventSource) {
trackers = Arrays.stream(AttributeCategory.values())
.map(category -> AttributeCategory.createTracker(category, eventBus, eventSource))
.collect(Collectors.toMap(
Tracker::getCategory,
tracker -> tracker,
(l, r) -> {
LOGGER.log(Level.WARNING, "duplicate keys " + l.getCategory() + " and " + r.getCategory());
return l;
},
() -> new EnumMap<>(AttributeCategory.class)
));
}
/**
* Replaces the currently tracked target with a new one.
*/
public void setTarget(@Nullable Object candidate) {
if (candidate != null && candidate == target) {
return;
}
target = candidate;
setTargetToAllTrackers();
}
/**
* Reloads (reads again) all attributes.
*/
public void reload() {
trackers.values().forEach(Tracker::reload);
}
/**
* Reloads (reads again) all attributes in the specified category.
*/
public void reloadCategory(AttributeCategory category) {
trackers.get(category).reload();
}
/**
* Reloads (reads again) the specified attribute from the given category.
*/
public void reloadAttribute(AttributeCategory category, String property) {
trackers.get(category).reload(property);
}
///////////////////////////////////////////////////////////////////////////
/**
* Reloads (reads again) all attributes in all trackers (categories).
*/
private void setTargetToAllTrackers() {
for (var tracker : trackers.values()) {
if (tracker.accepts(target)) {
tracker.setTarget(target);
} else {
tracker.reset();
}
}
}
}

View File

@ -0,0 +1,287 @@
package devtoolsfx.connector;
import devtoolsfx.util.SceneUtils;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
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
* highlight layoutBounds, a stroked rectangle to highlight boundsInParent, and a line
* to highlight the baselineOffset.
*/
@NullMarked
final class BoundsPane {
private static final Logger LOGGER = System.getLogger(BoundsPane.class.getName());
private final Rectangle boundsInParentRect = createBoundsInParentRect();
private final Rectangle layoutBoundsRect = createLayoutBoundsRect();
private final Line baselineStroke = createBaselineStroke();
private @Nullable Parent parent;
public BoundsPane() {
// pass
}
/**
* Attaches the overlay to the given parent node. When attached, the BoundsPane is able
* to display highlighting nodes above the parent node in the parent coordinate system.
* Use {@link #toggleLayoutBoundsDisplay(Node)}, {@link #toggleBoundsInParentDisplay(Node)}
* and {@link #toggleBaselineDisplay} to highlight any descendant nodes of the parent.
*/
public void attach(@Nullable Parent candidate) {
if (parent != null) {
detach();
}
// find parent we can use to hang bounds rectangles
parent = SceneUtils.findNearestPane(candidate);
if (parent == null) {
if (candidate != null) {
LOGGER.log(Level.WARNING, "Could not find writable parent to add overlay nodes, overlay is disabled");
}
toggleLayoutBoundsDisplay(null);
toggleBoundsInParentDisplay(null);
toggleBaselineDisplay(null);
} else {
SceneUtils.addToNode(parent, boundsInParentRect);
SceneUtils.addToNode(parent, layoutBoundsRect);
SceneUtils.addToNode(parent, baselineStroke);
}
}
/**
* Removes the overlay highlighting nodes.
*/
public void detach() {
if (parent != null) {
SceneUtils.removeFromNode(parent, boundsInParentRect);
SceneUtils.removeFromNode(parent, layoutBoundsRect);
SceneUtils.removeFromNode(parent, baselineStroke);
parent = null;
}
}
/**
* Updates the properties of the layoutBounds rectangle to set or remove the highlight
* for the specified target node. If the target node is null the current selection will
* be removed.
*/
public void toggleLayoutBoundsDisplay(@Nullable Node target) {
if (target == null || parent == null) {
hideRect(layoutBoundsRect);
return;
}
Bounds bounds = calcRelativeBounds(target, true);
if (bounds == null || !isFinite(bounds)) {
hideRect(layoutBoundsRect);
} else {
resizeRelocateRect(layoutBoundsRect, bounds);
layoutBoundsRect.setVisible(true);
}
}
/**
* Updates the properties of the boundsInParent rectangle to set or remove the highlight
* for the specified target node. If the target node is null the current selection will
* be removed.
*/
public void toggleBoundsInParentDisplay(@Nullable Node target) {
if (target == null || parent == null) {
hideRect(boundsInParentRect);
return;
}
Bounds bounds = calcRelativeBounds(target, false);
if (bounds == null || !isFinite(bounds)) {
hideRect(boundsInParentRect);
} else {
resizeRelocateRect(boundsInParentRect, bounds);
boundsInParentRect.setVisible(true);
}
}
/**
* Updates the baseline stroke properties to set or remove the baseline offset highlighting
* for the specified target node. If the target node is null the current selection will be removed.
*/
public void toggleBaselineDisplay(@Nullable Node target) {
// protect from the Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, Double.NaN
if (parent == null || target == null || target.getBaselineOffset() == Node.BASELINE_OFFSET_SAME_AS_HEIGHT) {
hideStroke(baselineStroke);
return;
}
Bounds b = target.getLayoutBounds();
Point2D scenePos = target.localToScene(b.getMinX(), b.getMinY() + target.getBaselineOffset());
Point2D localPos = parent.sceneToLocal(scenePos);
// No jokes: if one of the coordinates is not finite, it will (silently) break the rendering.
// The overlay rectangle will be "frozen" and will only "unfreeze" after a window resize.
if (!isFinite(localPos)) {
hideStroke(baselineStroke);
return;
}
baselineStroke.setStartX(localPos.getX());
baselineStroke.setEndX(localPos.getX() + b.getWidth());
baselineStroke.setStartY(localPos.getY());
baselineStroke.setEndY(localPos.getY());
baselineStroke.setVisible(true);
}
/**
* Calculates the bounds of the node relative to the current parent node.
*
* @param layoutBounds calculate for the layoutBounds, if false boundsInParent will be used
*/
public @Nullable Bounds calcRelativeBounds(Node node, boolean layoutBounds) {
if (parent == null) {
return null;
}
var bounds = node.getBoundsInParent();
double layoutX = 0, layoutY = 0;
if (layoutBounds) {
bounds = node.getLayoutBounds();
layoutX = node.getLayoutX();
layoutY = node.getLayoutY();
}
if (node.getParent() == null) {
// no parent, so the node is root
return new BoundingBox(
bounds.getMinX() + layoutX + 1, bounds.getMinY() + layoutY + 1,
bounds.getWidth() - 2, bounds.getHeight() - 2
);
} else {
Point2D scenePos = node.getParent().localToScene(bounds.getMinX(), bounds.getMinY());
Point2D parentPos = parent.sceneToLocal(scenePos);
return new BoundingBox(
parentPos.getX() + layoutX, parentPos.getY() + layoutY,
bounds.getWidth(), bounds.getHeight()
);
}
}
///////////////////////////////////////////////////////////////////////////
/**
* Creates a {@link Rectangle} that will be used in the overlay to highlight
* the {@link Node#boundsInParentProperty()} of a selected node.
*/
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;
}
/**
* Creates a {@link Rectangle} that will be used in the overlay to highlight
* the {@link Node#layoutBoundsProperty()} of a selected node.
*/
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;
}
/**
* Creates a {@link Line} that will be used in the overlay to highlight
* the {@link Node#getBaselineOffset()} of a selected node.
*/
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;
}
/**
* Hides the given line.
*/
private void hideStroke(Line line) {
line.setStartX(0);
line.setEndX(0);
line.setStartY(0);
line.setEndY(0);
line.setVisible(false);
}
/**
* Hides the given rectangle.
*/
private void hideRect(Rectangle rect) {
rect.setX(0);
rect.setY(0);
rect.setWidth(0);
rect.setHeight(0);
rect.setVisible(false);
}
/**
* Resizes and relocates the specified rectangle according to the provided bounds.
*/
private void resizeRelocateRect(Rectangle rect, Bounds bounds) {
rect.setX(bounds.getMinX());
rect.setY(bounds.getMinY());
rect.setWidth(bounds.getWidth());
rect.setHeight(bounds.getHeight());
}
/**
* Ensures that all point coordinates are finite double values.
*/
private boolean isFinite(Point2D point) {
return isFinite(point.getX()) && isFinite(point.getY());
}
/**
* Ensures that all bounds values are finite double values.
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
private boolean isFinite(Bounds bounds) {
return isFinite(bounds.getMinX())
&& isFinite(bounds.getMinY())
&& isFinite(bounds.getWidth())
&& isFinite(bounds.getHeight());
}
/**
* Ensures that double is finite (not Infinity or NaN).
*/
private boolean isFinite(double d) {
return Double.isFinite(d) && !Double.isNaN(d);
}
}

View File

@ -0,0 +1,121 @@
package devtoolsfx.connector;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.WindowProperties;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.application.Application;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.scene.Parent;
import javafx.scene.control.Control;
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
* {@link EventBus}. The client should subscribe to EventBus events to react to these changes.
*/
@NullMarked
public interface Connector {
/**
* Starts the connector, which monitors and reports on all existing and new application windows.
*/
void start();
/**
* The opposite of {@link #start()}.
*/
void stop();
/**
* Returns the start/stop state of the connector.
*/
ReadOnlyBooleanProperty startedProperty();
/**
* Returns the {@link EventBus} to react to the connector events.
*/
EventBus getEventBus();
/**
* Returns the list of event sources for all currently monitored objects.
*/
List<EventSource> getEventSources();
/**
* Returns the connector options.
*/
ConnectorOptions getOptions();
/**
* Returns the interface for accessing system or platform information
* about the monitored application.
*/
Env getEnv();
/**
* Selects and starts monitoring the attributes of the window and scene.
* This method is mutually exclusive with {@link #selectNode(int, Element, HighlightOptions)}.
*/
void selectWindow(int uid);
/**
* 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);
/**
* The opposite of {@link #selectNode(int, Element, HighlightOptions)}.
*
* @param uid see {@link EventSource#uid()}
*/
void clearSelection(int uid);
/**
* Reloads the attributes of the selected element, if any. If no property is specified,
* all category attributes will be reloaded. If no category is specified, all element
* attributes across all categories will be reloaded.
*/
void reloadSelectedAttributes(int uid, @Nullable AttributeCategory category, @Nullable String property);
/**
* Hides the specified window.
*
* @param uid see {@link EventSource#uid()}
*/
void hideWindow(int uid);
/**
* Returns the list of nodes (elements) with custom stylesheets, specifically those
* for which {@link Parent#getStylesheets()} or {@link Control#getStylesheets()} is not empty.
*
* @param uid see {@link EventSource#uid()}
*/
Map.@Nullable Entry<WindowProperties, List<Element>> getStyledElements(int uid);
/**
* Returns the {@link Application#getUserAgentStylesheet()} for the monitored application.
*/
String getUserAgentStylesheet();
/**
* Reads and returns the content of the file resource at the specified URI.
*/
@Nullable
String getResource(int uid, String uri);
/**
* Returns the owner class name for the given property.
* This method addresses the issue of finding the superclass that owns the property.
*/
@Nullable
String getDeclaringClass(String className, String property);
}

View File

@ -0,0 +1,72 @@
package devtoolsfx.connector;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.stage.PopupWindow;
import org.jspecify.annotations.NullMarked;
/**
* Contains all the supported {@link Connector} options.
* Every option is observable, so they are applied at runtime immediately.
*/
@NullMarked
public final class ConnectorOptions {
public static final String AUX_NODE_ID_PREFIX = "devtoolsfx.";
private final BooleanProperty ignoreMouseTransparent = new SimpleBooleanProperty(false);
private final BooleanProperty inspectMode = new SimpleBooleanProperty(false);
private final BooleanProperty preventPopupAutoHide = new SimpleBooleanProperty(false);
public ConnectorOptions() {
// pass
}
/**
* Enables the option to ignore mouse-transparent nodes when hovering,
* e.g., in inspect mode.
*/
BooleanProperty ignoreMouseTransparentProperty() {
return ignoreMouseTransparent;
}
public boolean isIgnoreMouseTransparent() {
return ignoreMouseTransparent.get();
}
public void setIgnoreMouseTransparent(boolean ignoreMouseTransparent) {
this.ignoreMouseTransparent.set(ignoreMouseTransparent);
}
/**
* Toggles the connector inspect mode. When enabled, the connector will display
* the {@link InspectPane} containing short information above any hovered node.
*/
BooleanProperty inspectModeProperty() {
return inspectMode;
}
public boolean isInspectMode() {
return inspectMode.get();
}
public void setInspectMode(boolean inspectMode) {
this.inspectMode.set(inspectMode);
}
/**
* Disables the {@link PopupWindow#autoHideProperty()} when the popup window appears.
* This allows inspection of the popup window content without accidentally hiding the window.
*/
BooleanProperty preventPopupAutoHideProperty() {
return preventPopupAutoHide;
}
public boolean isPreventPopupAutoHide() {
return preventPopupAutoHide.get();
}
public void setPreventPopupAutoHide(boolean preventPopupAutoHide) {
this.preventPopupAutoHide.set(preventPopupAutoHide);
}
}

View File

@ -0,0 +1,35 @@
package devtoolsfx.connector;
import java.util.List;
/**
* Provides system information about the monitored JavaFX application,
* including system properties, environment variables, platform preferences, and more.
*/
public interface Env {
/**
* Returns the list of system properties for the JavaFX JVM process.
*/
List<KeyValue> getSystemProperties();
/**
* Returns the list of env variables for the JavaFX JVM process.
*/
List<KeyValue> getEnvVariables();
/**
* Returns the list of conditional features for the monitored JavaFX application.
*/
List<KeyValue> getConditionalFeatures();
/**
* Returns the list of platform preferences for the monitored JavaFX application.
*/
List<KeyValue> getPlatformPreferences();
/**
* Returns the list of optional platform preferences for the monitored JavaFX application.
*/
List<KeyValue> getOtherPlatformProperties();
}

View File

@ -0,0 +1,19 @@
package devtoolsfx.connector;
import org.jspecify.annotations.NullMarked;
/**
* Contains the highlighting options to apply when selecting a node.
*/
@NullMarked
public record HighlightOptions(boolean showLayoutBounds,
boolean showBoundsInParent,
boolean showBaseline) {
/**
* Returns default {@link HighlightOptions}.
*/
public static HighlightOptions defaults() {
return new HighlightOptions(true, true, false);
}
}

View File

@ -0,0 +1,122 @@
package devtoolsfx.connector;
import devtoolsfx.util.ClassInfoCache;
import java.text.DecimalFormat;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import org.jspecify.annotations.NullMarked;
import static javafx.stage.PopupWindow.AnchorLocation;
/**
* The pane that is meant to be displayed above the hovered node.
* It visually highlights the node as well as displays the tooltip with short node information.
*/
@NullMarked
final class InspectPane extends Group {
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private final Rectangle rootBounds = new Rectangle();
private final Rectangle viewportBounds = new Rectangle();
private final Tooltip tooltip = new Tooltip();
public InspectPane() {
super();
setManaged(false);
setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "inspectPane");
tooltip.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "inspectPaneTooltip");
tooltip.setAnchorLocation(AnchorLocation.CONTENT_BOTTOM_RIGHT);
}
/**
* Shows the pane.
*
* @param node the target node
* @param boundsInParent the node's bounds relative to the {@link BoundsPane}
* @param rootWidth the width of the scene's root
* @param rootHeight the height of the scene's root
*/
public void show(Node node, Bounds boundsInParent, double rootWidth, double rootHeight) {
if (tooltip.isShowing()) {
hide();
}
rootBounds.setWidth(rootWidth);
rootBounds.setHeight(rootHeight);
double nodeWidth = boundsInParent.getMaxX() - boundsInParent.getMinX();
double nodeHeight = boundsInParent.getMaxY() - boundsInParent.getMinY();
viewportBounds.setLayoutX(boundsInParent.getMinX());
viewportBounds.setLayoutY(boundsInParent.getMinY());
viewportBounds.setWidth(nodeWidth);
viewportBounds.setHeight(nodeHeight);
// for some reason stage width may not be equal to the root node width,
// so we introduce some delta to compensate
if (rootWidth - nodeWidth < 2 && rootHeight - nodeHeight < 2) {
viewportBounds.setWidth(0);
viewportBounds.setHeight(0);
}
var curtain = Shape.subtract(rootBounds, viewportBounds);
curtain.setMouseTransparent(false);
curtain.setFill(Color.GREEN);
curtain.setOpacity(0.5);
getChildren().add(curtain);
Point2D screenXY = node.localToScreen(
node.getBoundsInLocal().getMinX(),
node.getBoundsInLocal().getMinY()
);
var header = ClassInfoCache.get(node).simpleClassName();
if (node.getId() != null) {
header += " id=\"" + node.getId() + "\"";
}
var styleClass = node.getStyleClass();
if (!styleClass.isEmpty()) {
header += " class=\"" + String.join(" ", styleClass) + "\"";
}
var text = """
%s
x: %s y: %s
width: %s height: %s
""".formatted(
header,
DECIMAL_FORMAT.format(boundsInParent.getMinX()),
DECIMAL_FORMAT.format(boundsInParent.getMinY()),
DECIMAL_FORMAT.format(nodeWidth),
DECIMAL_FORMAT.format(nodeHeight)
);
tooltip.setText(text);
tooltip.show(node.getScene().getWindow(), screenXY.getX(), screenXY.getY());
}
/**
* The opposite of {@link #show(Node, Bounds, double, double)}.
*/
public void hide() {
try {
getChildren().clear();
tooltip.hide();
} catch (Exception ignored) {
// UnsupportedOperationException when closing the monitored
// window without disabling the inspect mode
}
}
}

View File

@ -0,0 +1,41 @@
package devtoolsfx.connector;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Comparator;
import java.util.Map;
/**
* Represents a simple key-value pair of strings.
*/
@NullMarked
public record KeyValue(String key, @Nullable String value) implements Comparable<KeyValue> {
public static final Comparator<KeyValue> COMPARATOR = Comparator.comparing(KeyValue::key);
@Override
public boolean equals(Object target) {
if (this == target) return true;
if (!(target instanceof KeyValue keyValue)) return false;
return key.equals(keyValue.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
public static KeyValue of(Map.Entry<?, ?> entry) {
return new KeyValue(
String.valueOf(entry.getKey()),
String.valueOf(entry.getValue())
);
}
@Override
public int compareTo(KeyValue other) {
return COMPARATOR.compare(this, other);
}
}

View File

@ -0,0 +1,339 @@
package devtoolsfx.connector;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.event.ExceptionEvent;
import devtoolsfx.event.WindowClosedEvent;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.WindowProperties;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import devtoolsfx.util.SceneUtils;
import javafx.application.Application;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.collections.ListChangeListener;
import javafx.scene.control.PopupControl;
import javafx.stage.PopupWindow;
import javafx.stage.Stage;
import javafx.stage.Window;
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}.
*/
@NullMarked
public final class LocalConnector implements Connector {
private static final Logger LOGGER = System.getLogger(LocalConnector.class.getName());
private final String application;
private final ConnectorOptions opts;
private final EventBus eventBus = new EventBus();
private final Env env = new LocalEnv();
private final Map<Integer, WindowMonitor> monitors = new HashMap<>();
private final ListChangeListener<Window> windowListChangeListener = this::onWindowListChanged;
private final ReadOnlyBooleanWrapper started = new ReadOnlyBooleanWrapper();
/**
* See {@link LocalConnector#(Stage, ConnectorOptions , String)}.
*/
public LocalConnector(Stage primaryStage) {
this(primaryStage, null, null);
}
/**
* See {@link LocalConnector#(Stage, ConnectorOptions , String)}.
*/
public LocalConnector(Stage primaryStage, @Nullable String application) {
this(primaryStage, application, null);
}
/**
* Creates a new connector.
* Only one connector per app should be created.
*
* @param primaryStage the target app's primary stage
* @param application the target app's name that will be reported in events,
* see {@link EventSource#application()}
* @param opts the connector options
*/
public LocalConnector(Stage primaryStage,
@Nullable String application,
@Nullable ConnectorOptions opts) {
Objects.requireNonNull(primaryStage, "primary stage must not be null");
this.application = Objects.requireNonNullElse(application, "app-" + primaryStage.hashCode());
this.opts = Objects.requireNonNullElse(opts, new ConnectorOptions());
monitors.put(uidOf(primaryStage), createMonitor(primaryStage, application, true));
this.opts.inspectModeProperty().addListener((obs, old, val) -> {
if (!val) {
// prevents ConcurrentModificationException
new ArrayList<>(monitors.values()).forEach(monitor -> monitor.setInspectMode(false));
}
});
}
@Override
public void start() {
started.set(true);
monitors.forEach((hash, monitor) -> monitor.start());
Window.getWindows().addListener(windowListChangeListener);
LOGGER.log(Level.INFO, "LocalConnector started");
}
@Override
public void stop() {
started.set(false);
monitors.forEach((hash, monitor) -> monitor.stop());
Window.getWindows().removeListener(windowListChangeListener);
LOGGER.log(Level.INFO, "LocalConnector stopped");
}
@Override
public ReadOnlyBooleanProperty startedProperty() {
return started.getReadOnlyProperty();
}
@Override
public EventBus getEventBus() {
return eventBus;
}
@Override
public List<EventSource> getEventSources() {
return monitors.values().stream().map(WindowMonitor::getEventSource).toList();
}
@Override
public ConnectorOptions getOptions() {
return opts;
}
@Override
public Env getEnv() {
return env;
}
@Override
public void selectWindow(int uid) {
var monitor = monitors.get(uid);
if (monitor != null) {
monitor.selectWindow();
} else {
LOGGER.log(Level.WARNING, "Unable to select window: unknown window UID");
}
}
@Override
public void selectNode(int uid, Element element, @Nullable HighlightOptions opts) {
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()));
} else {
LOGGER.log(Level.WARNING, "Unable to select element: unknown node");
}
} else {
LOGGER.log(Level.WARNING, "Unable to select element: unknown window UID");
}
}
@Override
public void clearSelection(int uid) {
var monitor = monitors.get(uid);
if (monitor != null) {
monitor.clearSelection();
} else {
LOGGER.log(Level.WARNING, "Unable to clear selection: unknown window UID");
}
}
@Override
public void reloadSelectedAttributes(int uid,
@Nullable AttributeCategory category,
@Nullable String property) {
var monitor = monitors.get(uid);
if (monitor != null) {
monitor.reloadSelectedAttributes(category, property);
} else {
LOGGER.log(Level.WARNING, "Unable to reload attributes: unknown window UID");
}
}
@Override
public void hideWindow(int uid) {
var monitor = monitors.get(uid);
if (monitor != null) {
monitor.hideWindow();
}
}
@Override
public Map.@Nullable Entry<WindowProperties, List<Element>> getStyledElements(int uid) {
var monitor = monitors.get(uid);
if (monitor != null) {
return monitor.getStyledElements();
}
return null;
}
@Override
public String getUserAgentStylesheet() {
var uas = Application.getUserAgentStylesheet();
// not optimal, but there's no API to obtain platform's UA stylesheets URLs,
// for the reference, they're in the StyleManager#platformUserAgentStylesheetContainers
return Objects.requireNonNullElse(uas, Application.STYLESHEET_MODENA);
}
@Override
public @Nullable String getResource(int uid, String uri) {
// security check to avoid reading an arbitrary file
var monitor = monitors.get(uid);
if (monitor == null || !monitor.containsStylesheet(uri)) {
return null;
}
String content = null;
try {
content = Files.readString(
Paths.get(URI.create(uri).getPath())
);
} catch (Exception e) {
eventBus.fire(ExceptionEvent.of(monitor.getEventSource(), e));
}
return content;
}
@Override
public @Nullable String getDeclaringClass(String canonicalName, String property) {
try {
Class<?> cls = getDeclaringClass(Class.forName(canonicalName), property);
return cls != null ? cls.getCanonicalName() : null;
} catch (ClassNotFoundException e) {
return null;
}
}
private @Nullable Class<?> getDeclaringClass(Class<?> cls, String method) {
try {
cls.getDeclaredMethod(method);
return cls;
} catch (NoSuchMethodException e) {
Class<?> superClass = cls.getSuperclass();
if (superClass != null) {
return getDeclaringClass(superClass, method);
}
}
return null;
}
///////////////////////////////////////////////////////////////////////////
/**
* Instantiates a new {@link WindowMonitor}.
*/
private WindowMonitor createMonitor(Window window,
@Nullable String application,
boolean isPrimaryStage) {
int uid = uidOf(window);
var app = application;
if (app == null && window instanceof Stage stage) {
app = stage.getTitle();
}
if (app == null) {
app = "unknown#" + uid;
}
var eventSource = new EventSource(app, uid, isPrimaryStage);
return new WindowMonitor(window, opts, eventBus, eventSource);
}
/**
* Handles reported {@link Window#getWindows()} list changes.
*/
private void onWindowListChanged(ListChangeListener.Change<? extends Window> change) {
while (change.next()) {
new ArrayList<>(change.getAddedSubList()).forEach(this::handleWindowAdd);
new ArrayList<>(change.getRemoved()).forEach(this::handleWindowRemove);
}
}
// package-private for unit tests
void handleWindowAdd(Window window) {
int uid = uidOf(window);
// ignore auxiliary tooltips (inspect mode) and context menus
if (window instanceof PopupControl popup && (
// context menu with ID || menu button with ID
SceneUtils.isAuxiliaryNode(popup) || SceneUtils.isAuxiliaryNode(popup.getOwnerNode()))
) {
return;
}
// ignore any other auxiliary windows
if (window.getScene() != null && SceneUtils.isAuxiliaryNode(window.getScene().getRoot())) {
return;
}
// guard block, should never happen
if (monitors.containsKey(uid)) {
LOGGER.log(Level.ERROR, "Attempting to add a new window that already has a bound monitor object");
monitors.remove(uid);
handleWindowAdd(window);
}
var monitor = createMonitor(window, application, false);
monitor.setInspectMode(opts.isInspectMode());
if (window instanceof PopupWindow popup) {
try {
if (opts.isPreventPopupAutoHide()) {
popup.setAutoHide(false);
}
} catch (Exception ignored) {
// some resource contention when autoHide=true
}
}
monitors.put(uid, monitor);
monitor.start();
}
// package-private for unit tests
void handleWindowRemove(Window window) {
var uid = uidOf(window);
var monitor = monitors.get(uid);
if (monitor != null) {
monitor.stop();
monitors.remove(uid);
eventBus.fire(new WindowClosedEvent(monitor.getEventSource()));
}
}
/**
* Returns unique window ID.
*/
private int uidOf(Window window) {
return window.hashCode();
}
}

View File

@ -0,0 +1,243 @@
package devtoolsfx.connector;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.*;
import devtoolsfx.util.ClassInfoCache;
import devtoolsfx.util.SceneUtils;
import javafx.scene.Node;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* The {@link Element} implementation that directly wraps a link to the scene graph
* node and must not leak the JVM process (e.g. by transferring it via the network).
*/
@NullMarked
public final class LocalElement implements Element {
private final int uid;
private final ClassInfo classInfo;
private final Vertex vertex;
private final @Nullable NodeProperties nodeProperties;
private final @Nullable WindowProperties windowProperties;
private final @Nullable Node node;
private LocalElement(int uid,
ClassInfo classInfo,
Vertex vertex,
@Nullable NodeProperties nodeProperties,
@Nullable WindowProperties windowProperties,
@Nullable Node node) {
this.uid = uid;
this.classInfo = Objects.requireNonNull(classInfo, "class info must not be null");
this.vertex = Objects.requireNonNull(vertex, "vertex must not be null");
if (nodeProperties != null && windowProperties != null) {
throw new IllegalArgumentException(
"Either nodeProperties or windowProperties must be null, as it signifies the type of the element"
);
}
if (nodeProperties == null && windowProperties == null) {
throw new IllegalArgumentException(
"Either nodeProperties or windowProperties must be specified, as it signifies the type of the element"
);
}
this.nodeProperties = nodeProperties;
this.windowProperties = windowProperties;
this.node = node;
}
@Override
public int getUID() {
return uid;
}
@Override
public ClassInfo getClassInfo() {
return classInfo;
}
@Override
public @Nullable Element getParent() {
return vertex.getParent();
}
@Override
public List<Element> getChildren() {
return vertex.getChildren();
}
@Override
public boolean hasChildren() {
return vertex.hasChildren();
}
@Override
public @Nullable NodeProperties getNodeProperties() {
return nodeProperties;
}
@Override
public @Nullable WindowProperties getWindowProperties() {
return windowProperties;
}
@Override
public boolean isWindowElement() {
return windowProperties != null;
}
@Override
public boolean isNodeElement() {
return nodeProperties != null;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof LocalElement that)) {
return false;
}
return uid == that.uid;
}
@Override
public int hashCode() {
return uid;
}
@Override
public String toString() {
return "LocalElement{" +
"uid=" + uid +
", classInfo=" + classInfo +
", vertex=" + vertex +
", nodeProperties=" + nodeProperties +
", windowProperties=" + windowProperties +
", node=" + node +
'}';
}
/**
* If the element is a wrapper around {@link Node}, unwraps the target node.
*/
public @Nullable Node unwrap() {
return node;
}
///////////////////////////////////////////////////////////////////////////
/**
* Creates a new Element from the target JavaFX node.
*/
public static Element of(Node node) {
Objects.requireNonNull(node, "node cannot be null");
return new LocalElement(
node.hashCode(),
ClassInfoCache.get(node),
new NodeVertex(node),
NodeProperties.of(node),
null,
node
);
}
/**
* Creates a new Element for the given window.
*/
public static Element of(Window window, EventSource eventSource, @Nullable Element root) {
Objects.requireNonNull(window, "window cannot be null");
return new LocalElement(
eventSource.uid(),
ClassInfoCache.get(window),
new WindowVertex(root),
null,
WindowProperties.of(window, eventSource.isPrimaryStage()),
null
);
}
/**
* See {@link LocalElement#of(Window, EventSource, Element)}.
*/
public static Element of(Window window, EventSource eventSource) {
Objects.requireNonNull(window, "window cannot be null");
Element root = null;
if (window.getScene() != null && window.getScene().getRoot() != null) {
root = LocalElement.of(window.getScene().getRoot());
}
return LocalElement.of(window, eventSource, root);
}
///////////////////////////////////////////////////////////////////////////
@NullMarked
static final class NodeVertex implements Vertex {
private final Node node;
public NodeVertex(Node node) {
this.node = node;
}
@Override
public @Nullable Element getParent() {
if (node.getParent() != null) {
return of(node.getParent());
}
return null;
}
@Override
public List<Element> getChildren() {
return SceneUtils.getChildren(node)
.stream()
.map(LocalElement::of)
.collect(Collectors.toList());
}
@Override
public boolean hasChildren() {
return SceneUtils.getChildren(node).isEmpty();
}
}
@NullMarked
static final class WindowVertex implements Vertex {
private final @Nullable Element root;
public WindowVertex(@Nullable Element root) {
this.root = root;
}
@Override
public @Nullable Element getParent() {
return null;
}
@Override
public List<Element> getChildren() {
return root != null ? List.of(root) : List.of();
}
@Override
public boolean hasChildren() {
return root != null;
}
}
}

View File

@ -0,0 +1,99 @@
package devtoolsfx.connector;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.scene.input.KeyCode;
import javafx.scene.paint.Color;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@NullMarked
public final class LocalEnv implements Env {
@Override
public List<KeyValue> getSystemProperties() {
return System.getProperties().entrySet().stream()
.map(KeyValue::of)
.toList();
}
@Override
public List<KeyValue> getEnvVariables() {
return System.getenv().entrySet().stream()
.map(KeyValue::of)
.toList();
}
@Override
public List<KeyValue> getConditionalFeatures() {
return Arrays.stream(ConditionalFeature.values())
.map(cf -> new KeyValue(
"ConditionalFeature." + cf.toString(),
String.valueOf(Platform.isSupported(cf)))
)
.toList();
}
@Override
public List<KeyValue> getPlatformPreferences() {
var preferences = Platform.getPreferences();
var staticPreferences = Stream.of(
new KeyValue("Preferences.colorScheme", String.valueOf(preferences.getColorScheme())),
new KeyValue("Preferences.accentColor", colorToHexString(preferences.getAccentColor())),
new KeyValue("Preferences.backgroundColor", colorToHexString(preferences.getBackgroundColor())),
new KeyValue("Preferences.foregroundColor", colorToHexString(preferences.getForegroundColor()))
);
var uiPreferences = preferences.entrySet().stream().map(entry -> {
if (entry.getValue() instanceof Color color) {
return new KeyValue(entry.getKey(), colorToHexString(color));
}
return KeyValue.of(entry);
});
return Stream.concat(staticPreferences, uiPreferences).toList();
}
@Override
public List<KeyValue> getOtherPlatformProperties() {
return List.of(
new KeyValue("accessibilityActive", String.valueOf(Platform.isAccessibilityActive())),
new KeyValue("implicitExit", String.valueOf(Platform.isImplicitExit())),
new KeyValue("keyLocked.CAPS", unwrap(Platform.isKeyLocked(KeyCode.CAPS))),
new KeyValue("keyLocked.NUM_LOCK", unwrap(Platform.isKeyLocked(KeyCode.NUM_LOCK)))
);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private String unwrap(Optional<Boolean> opt) {
return String.valueOf(opt.isPresent() && opt.get());
}
private static String colorToHexString(@Nullable Color color) {
if (color == null) {
return "";
}
if (color.getOpacity() == 1) {
return String.format("#%02X%02X%02X",
(int) (color.getRed() * 255),
(int) (color.getGreen() * 255),
(int) (color.getBlue() * 255)
).toUpperCase();
}
return String.format("#%02X%02X%02X%02X",
(int) (color.getRed() * 255),
(int) (color.getGreen() * 255),
(int) (color.getBlue() * 255),
(int) (color.getOpacity() * 255)
).toUpperCase();
}
}

View File

@ -0,0 +1,596 @@
package devtoolsfx.connector;
import devtoolsfx.event.*;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.WindowProperties;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import devtoolsfx.util.SceneUtils;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import javafx.stage.Window;
import javafx.util.Subscription;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.*;
@NullMarked
final class WindowMonitor {
private final Window window;
private final ConnectorOptions connectorOpts;
private final EventBus eventBus;
private final EventSource eventSource;
// highlighting
private final BoundsPane boundsPane;
private final InspectPane inspectPane;
private @Nullable Node hoveredNode;
private @Nullable Node selectedNode;
private HighlightOptions highlightOpts = HighlightOptions.defaults();
// attributes
private final AttributeListener attributeListener;
private boolean started;
private final Map<Integer, Subscription> stylesClassSubs = new HashMap<>();
/**
* Creates a new WindowMonitor instance. Monitors are not reusable; each instance must
* be connected to a different {@link Window}.
*
* @param window the monitored window
* @param connectorOpts options for {@link LocalConnector}
* @param eventBus the event bus instance to track monitor events
* @param eventSource the event source to be included in all emitted events
*/
public WindowMonitor(Window window,
ConnectorOptions connectorOpts,
EventBus eventBus,
EventSource eventSource) {
this.window = window;
this.connectorOpts = connectorOpts;
this.eventBus = eventBus;
this.eventSource = eventSource;
this.boundsPane = new BoundsPane();
this.inspectPane = new InspectPane();
this.attributeListener = new AttributeListener(eventBus, eventSource);
connectorOpts.inspectModeProperty().addListener((obs, old, val) -> refreshRoot());
}
///////////////////////////////////////////////////////////////////////////
// Public API //
///////////////////////////////////////////////////////////////////////////
/**
* Starts the monitor.
*/
public void start() {
started = true;
window.xProperty().addListener(windowPropertyReportListener);
window.yProperty().addListener(windowPropertyReportListener);
window.widthProperty().addListener(windowPropertyReportListener);
window.heightProperty().addListener(windowPropertyReportListener);
window.focusedProperty().addListener(windowPropertyReportListener);
window.sceneProperty().addListener(sceneChangeListener);
changeScene(null, window.getScene());
fire(WindowPropertiesEvent.of(eventSource, window));
}
/**
* Stops the monitor.
*/
public void stop() {
started = false;
window.xProperty().removeListener(windowPropertyReportListener);
window.yProperty().removeListener(windowPropertyReportListener);
window.widthProperty().removeListener(windowPropertyReportListener);
window.heightProperty().removeListener(windowPropertyReportListener);
window.focusedProperty().removeListener(windowPropertyReportListener);
window.sceneProperty().removeListener(sceneChangeListener);
changeScene(getScene(), null);
// cleanup resources
clearSelection();
inspectPane.hide();
}
/**
* Returns the {@link EventBus} to react to the monitor events.
*/
public EventBus getEventBus() {
return eventBus;
}
/**
* Returns the event source that is included in all emitted events by the monitor.
*/
public EventSource getEventSource() {
return eventSource;
}
/**
* See {@link Connector#selectWindow(int)}.
*/
public void selectWindow() {
if (selectedNode != null) {
clearSelection();
}
attributeListener.setTarget(window);
}
/**
* See {@link Connector#selectNode(int, Element, HighlightOptions)}.
*/
public void selectNode(@Nullable Node node, @Nullable HighlightOptions opts) {
Node prevNode = selectedNode;
if (prevNode != null) {
prevNode.boundsInParentProperty().removeListener(selectedNodeBoundsListener);
prevNode.layoutBoundsProperty().removeListener(selectedNodeBoundsListener);
}
if (node == null) {
clearSelection();
return;
}
highlightOpts = Objects.requireNonNullElse(opts, HighlightOptions.defaults());
selectedNode = node;
selectedNode.boundsInParentProperty().addListener(selectedNodeBoundsListener);
selectedNode.layoutBoundsProperty().addListener(selectedNodeBoundsListener);
boundsPane.toggleLayoutBoundsDisplay(highlightOpts.showLayoutBounds() ? selectedNode : null);
boundsPane.toggleBoundsInParentDisplay(highlightOpts.showBoundsInParent() ? selectedNode : null);
boundsPane.toggleBaselineDisplay(highlightOpts.showBaseline() ? selectedNode : null);
attributeListener.setTarget(selectedNode);
}
/**
* The opposite of {@link #selectNode(Node, HighlightOptions)}.
*/
public void clearSelection() {
selectedNode = null;
attributeListener.setTarget(null);
highlightOpts = HighlightOptions.defaults();
boundsPane.detach();
}
/**
* Returns a scene graph node with the given hash code.
*/
public @Nullable Node findNode(int hashCode) {
if (getRoot() == null) {
return null;
}
return SceneUtils.findNode(getRoot(), hashCode);
}
/**
* Sets the inspect mode on the monitored object to on or off.
*/
public void setInspectMode(boolean enabled) {
if (!enabled) {
inspectPane.hide();
}
}
/**
* Hides the monitored window.
*/
public void hideWindow() {
window.hide();
}
/**
* See {@link Connector#getStyledElements(int)}.
*/
public Map.@Nullable Entry<WindowProperties, List<Element>> getStyledElements() {
List<Element> result = new ArrayList<>();
if (getRoot() != null) {
SceneUtils.collectNodesWithStyleSheets(getRoot(), result);
}
WindowProperties props = null;
if (getScene() != null) {
props = WindowProperties.of(window, eventSource.isPrimaryStage());
}
return props != null ? new AbstractMap.SimpleEntry<>(props, result) : null;
}
/**
* Ensures that at least one scene graph node references the given stylesheet URI.
*/
public boolean containsStylesheet(String uri) {
if (getScene() != null && (
Objects.equals(getScene().getUserAgentStylesheet(), uri) || getScene().getStylesheets().contains(uri))
) {
return true;
}
return getRoot() != null && SceneUtils.containsStylesheet(getRoot(), uri);
}
/**
* See {@link Connector#reloadSelectedAttributes(int, AttributeCategory, String)}.
*/
public void reloadSelectedAttributes(@Nullable AttributeCategory category, @Nullable String property) {
if (category != null && property != null) {
attributeListener.reloadAttribute(category, property);
return;
}
if (category != null) {
attributeListener.reloadCategory(category);
return;
}
attributeListener.reload();
}
/**
* Retrieves the scene of the monitored window.
*/
public @Nullable Scene getScene() {
return window.getScene();
}
/**
* Retrieves the scene root of the monitored window.
*/
public @Nullable Parent getRoot() {
return getScene() != null ? getScene().getRoot() : null;
}
///////////////////////////////////////////////////////////////////////////
// Listeners //
///////////////////////////////////////////////////////////////////////////
/**
* Called when the scene's root {@link Parent} node changes.
*/
private final ChangeListener<Parent> sceneRootChangeListener = (obs, old, val) -> changeRoot(old, val);
/**
* Called when the window's Scene value changes.
*/
private final ChangeListener<Scene> sceneChangeListener = (obs, old, val) -> changeScene(old, val);
/**
* Called when any of the window's properties change.
*/
private final InvalidationListener windowPropertyReportListener = obs -> onWindowPropertyChanged();
private void onWindowPropertyChanged() {
fire(WindowPropertiesEvent.of(eventSource, window));
}
/**
* Handles mouse move events to highlight a hovered node.
*/
private final EventHandler<? super MouseEvent> mouseMoveHighlightFilter = this::onMouseHover;
private void onMouseHover(MouseEvent event) {
highlightHoveredNode(event);
}
/**
* Handles mouse move events to select a clicked node.
*/
private final EventHandler<? super MouseEvent> mousePressSelectFilter = this::onMousePressed;
private void onMousePressed(MouseEvent event) {
Node node = getHoveredNode(event);
if (node != null && connectorOpts.isInspectMode()) {
fire(new NodeSelectedEvent(eventSource, LocalElement.of(node)));
}
}
/**
* Reports mouse coordinates via the event bus.
*/
private final EventHandler<? super MouseEvent> mousePosReportFilter = this::onMouseMoved;
private void onMouseMoved(MouseEvent event) {
fire(MousePosEvent.of(eventSource, event));
}
/**
* Listens to the node's children list and handles changes accordingly.
*/
private final ListChangeListener<Node> nodeChildrenListener = this::onNodeChildrenChanged;
private void onNodeChildrenChanged(ListChangeListener.Change<? extends Node> change) {
while (change.next()) {
for (var dead : change.getRemoved()) {
removeNodeBranchListenersAndNotify(dead);
}
for (var alive : change.getAddedSubList()) {
addNodeBranchListenersAndNotify(alive);
}
}
}
/**
* Tracks the node's visibility state.
*/
private final ChangeListener<Boolean> nodeVisibilityChangeListener = this::onNodeVisibilityChanged;
@SuppressWarnings("unchecked")
private void onNodeVisibilityChanged(Observable obs, Boolean wasVisible, Boolean nowVisible) {
var node = (Node) ((Property<Boolean>) obs).getBean();
fire(new NodeVisibilityEvent(eventSource, LocalElement.of(node), nowVisible));
}
/**
* Reports all {@link Event#ANY} events for the node via the event bus.
*/
private final EventHandler<? super Event> nodeEventLogFilter = this::onNodeAnyEvent;
private void onNodeAnyEvent(Event event) {
fire(new JavaFXEvent(
eventSource, LocalElement.of((Node) event.getSource()), event.getEventType(), String.valueOf(event)
));
}
/**
* Updates the selected node highlighting when its bounds change due to resizing.
*/
private final InvalidationListener selectedNodeBoundsListener = new InvalidationListener() {
private boolean recursive; // prevent stack overflow
@Override
public void invalidated(Observable obs) {
if (!recursive) {
recursive = true;
boundsPane.toggleLayoutBoundsDisplay(highlightOpts.showLayoutBounds() ? selectedNode : null);
boundsPane.toggleBoundsInParentDisplay(highlightOpts.showBoundsInParent() ? selectedNode : null);
recursive = false;
}
}
};
/**
* Changes the monitored {@link Scene}.
*/
private void changeScene(@Nullable Scene oldScene, @Nullable Scene newScene) {
// unsubscribe
Parent oldRoot = null;
if (oldScene != null) {
SceneUtils.removeListener(oldScene, Scene::rootProperty, sceneRootChangeListener);
SceneUtils.removeEventFilter(oldScene, MouseEvent.MOUSE_MOVED, mouseMoveHighlightFilter);
oldRoot = oldScene.getRoot();
}
// subscribe
Parent newRoot = null;
if (newScene != null) {
SceneUtils.addListener(newScene, Scene::rootProperty, sceneRootChangeListener);
SceneUtils.addEventFilter(newScene, MouseEvent.MOUSE_MOVED, mouseMoveHighlightFilter);
newRoot = newScene.getRoot();
}
changeRoot(oldRoot, newRoot);
}
/**
* Changes the monitored {@link Parent} root node.
*/
private void changeRoot(@Nullable Parent oldRoot, @Nullable Parent newRoot, boolean force) {
// For alerts when we close the dialog, JavaFX caches the stage for reuse and changes
// the dialog pane to an empty root node. Thus, despite being stopped by the connector,
// the stage can continue to generate events, which can break the UI. That's why we first
// have to check that the monitor is still active.
if (!started) {
return;
}
if (!force && oldRoot == newRoot) {
return;
}
if (oldRoot != null) {
removeNodeBranchListeners(oldRoot);
SceneUtils.removeEventFilter(oldRoot, MouseEvent.MOUSE_MOVED, mouseMoveHighlightFilter);
SceneUtils.removeEventFilter(oldRoot, MouseEvent.MOUSE_MOVED, mousePosReportFilter);
SceneUtils.removeEventFilter(oldRoot, MouseEvent.MOUSE_PRESSED, mousePressSelectFilter);
SceneUtils.removeFromNode(oldRoot, inspectPane);
}
if (newRoot != null) {
addNodeBranchListeners(newRoot);
SceneUtils.addEventFilter(newRoot, MouseEvent.MOUSE_MOVED, mouseMoveHighlightFilter);
SceneUtils.addEventFilter(newRoot, MouseEvent.MOUSE_MOVED, mousePosReportFilter);
SceneUtils.addEventFilter(newRoot, MouseEvent.MOUSE_PRESSED, mousePressSelectFilter);
SceneUtils.addToNode(newRoot, inspectPane);
}
boundsPane.attach(newRoot);
notifyRootChanged(newRoot);
}
/**
* See {@link #changeRoot(Parent, Parent, boolean)}.
*/
private void changeRoot(@Nullable Parent oldRoot, @Nullable Parent newRoot) {
changeRoot(oldRoot, newRoot, false);
}
/**
* See {@link #changeRoot(Parent, Parent, boolean)}.
*/
private void refreshRoot() {
changeRoot(getRoot(), getRoot(), true);
}
///////////////////////////////////////////////////////////////////////////
// Operations with tracked scene graph node //
///////////////////////////////////////////////////////////////////////////
/**
* Notifies the {@link LocalConnector} client that this scene's root node
* has been changed, so they can update the UI accordingly.
*/
public void notifyRootChanged(@Nullable Parent root) {
var windowElement = LocalElement.of(window, eventSource, root != null ? LocalElement.of(root) : null);
fire(new RootChangedEvent(eventSource, windowElement));
}
/**
* Adds a set of listeners to the entire branch starting from the
* specified node to respond to state changes.
*/
private void addNodeBranchListeners(Node node) {
if (SceneUtils.isAuxiliaryNode(node)) {
return;
}
node.visibleProperty().removeListener(nodeVisibilityChangeListener);
node.visibleProperty().addListener(nodeVisibilityChangeListener);
node.removeEventFilter(Event.ANY, nodeEventLogFilter);
node.addEventFilter(Event.ANY, nodeEventLogFilter);
stylesClassSubs.put(node.hashCode(), node.getStyleClass().subscribe(() -> fire(new NodeStyleClassEvent(
eventSource, LocalElement.of(node), Collections.unmodifiableList(node.getStyleClass())
))));
ObservableList<Node> children = SceneUtils.getChildren(node);
children.removeListener(nodeChildrenListener);
children.addListener(nodeChildrenListener);
for (var child : children) {
addNodeBranchListeners(child);
}
}
/**
* The opposite of {@link #addNodeBranchListeners(Node)}.
* <p>
* When we are removing a node:
* - if it's a real node removal removeVisibilityListener is true
* - if it's a visibility remove we should remove the visibility listeners
* of its children because the visibility is reduced by their parent
*/
private void removeNodeBranchListeners(Node node) {
ObservableList<Node> children = SceneUtils.getChildren(node);
for (var child : children) {
removeNodeBranchListeners(child);
}
children.removeListener(nodeChildrenListener);
node.visibleProperty().removeListener(nodeVisibilityChangeListener);
node.removeEventFilter(Event.ANY, nodeEventLogFilter);
var subscription = stylesClassSubs.get(node.hashCode());
if (subscription != null) {
subscription.unsubscribe();
}
}
/**
* Adds a set of listeners to respond to node state changes and emits a {@code node added} event.
*/
private void addNodeBranchListenersAndNotify(Node node) {
if (!SceneUtils.isAuxiliaryNode(node)) {
addNodeBranchListeners(node);
fire(NodeAddedEvent.of(eventSource, LocalElement.of(node)));
}
}
/**
* Removes the set of listeners that was added to respond to node state changes and emits
* a {@code node removed} event.
*/
private void removeNodeBranchListenersAndNotify(Node node) {
if (!SceneUtils.isAuxiliaryNode(node)) {
removeNodeBranchListeners(node);
fire(new NodeRemovedEvent(eventSource, LocalElement.of(node)));
}
}
///////////////////////////////////////////////////////////////////////////
// Highlighting //
///////////////////////////////////////////////////////////////////////////
/**
* Returns the hovered node inside the scene based on the {@link MouseEvent} coordinates.
*/
private @Nullable Node getHoveredNode(MouseEvent event) {
if (getRoot() == null) {
return null;
}
return SceneUtils.findHoveredNode(
getRoot(), event.getX(), event.getY(), connectorOpts.isIgnoreMouseTransparent()
);
}
/**
* Highlights a hovered node based on the {@link MouseEvent} coordinates.
*/
private void highlightHoveredNode(MouseEvent event) {
if (!connectorOpts.isInspectMode()) {
return;
}
Node node = getHoveredNode(event);
if (node != null && hoveredNode == node) {
return;
}
hoveredNode = node;
if (hoveredNode != null) {
if (SceneUtils.isAuxiliaryNode(hoveredNode)) {
return;
}
if (SceneUtils.getWindow(hoveredNode) instanceof Tooltip tooltip
&& SceneUtils.isAuxiliaryNode(tooltip)) {
return;
}
var nodeBounds = boundsPane.calcRelativeBounds(hoveredNode, false);
if (nodeBounds != null) {
inspectPane.show(hoveredNode, nodeBounds, window.getWidth(), window.getHeight());
}
return;
}
inspectPane.hide();
}
///////////////////////////////////////////////////////////////////////////
// Utility Methods //
///////////////////////////////////////////////////////////////////////////
/**
* Fires the given event via the event bus when the monitor is started.
*/
private <T extends ConnectorEvent> void fire(T event) {
if (started) {
eventBus.fire(event);
}
}
}

View File

@ -0,0 +1,5 @@
/**
* The package contains the API for monitoring target node scene-graph changes.
*/
package devtoolsfx.connector;

View File

@ -0,0 +1,39 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import org.jspecify.annotations.NullMarked;
import java.util.List;
import java.util.stream.Collectors;
/**
* Notifies about detected attribute changes in a specific {@link AttributeCategory}.
*
* @param eventSource the event source
* @param element the element whose attributes have been changed
* @param category the attribute category
* @param attributes the list of changed attributes
*/
@NullMarked
public record AttributeListEvent(EventSource eventSource,
Element element,
AttributeCategory category,
List<Attribute<?>> attributes) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | category=" + category
+ " | attributes=["
+ attributes.stream().map(Attribute::toLogString).collect(Collectors.joining("; "))
+ "]";
}
}

View File

@ -0,0 +1,36 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about a single attribute change in a specific category.
* The difference from the {@link AttributeListEvent} is semantic,
* differentiating category changes from a single attribute update.
*
* @param eventSource the event source
* @param element the element whose attributes have been changed
* @param category the attribute category
* @param attribute the changed attribute
*/
@NullMarked
public record AttributeUpdatedEvent(EventSource eventSource,
Element element,
AttributeCategory category,
Attribute<?> attribute) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | category: " + category
+ " | attribute: " + attribute.toLogString();
}
}

View File

@ -0,0 +1,28 @@
package devtoolsfx.event;
import devtoolsfx.connector.Connector;
import org.jspecify.annotations.NullMarked;
/**
* The base sealed interface for all {@link Connector} events.
*/
@NullMarked
public sealed interface ConnectorEvent permits
AttributeListEvent,
AttributeUpdatedEvent,
ExceptionEvent,
JavaFXEvent,
MousePosEvent,
NodeAddedEvent,
NodeRemovedEvent,
NodeSelectedEvent,
NodeStyleClassEvent,
NodeVisibilityEvent,
RootChangedEvent,
WindowClosedEvent,
WindowPropertiesEvent {
EventSource eventSource();
String toLogString();
}

View File

@ -0,0 +1,11 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
/**
* Signifies that the event has been triggered by an element and provides access to that element.
*/
public interface ElementEvent {
Element getElement();
}

View File

@ -0,0 +1,81 @@
package devtoolsfx.event;
import devtoolsfx.connector.LocalConnector;
import javafx.application.Platform;
import org.jspecify.annotations.NullMarked;
import java.lang.System.Logger;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import static java.lang.System.Logger.Level;
/**
* A straightforward event bus implementation. Events are published in channels
* distinguished by event type. It must only be called from the FXThread.
*/
@NullMarked
public final class EventBus {
private static final Logger LOGGER = System.getLogger(LocalConnector.class.getName());
private final Map<Class<?>, Set<Consumer<?>>> subscribers = new ConcurrentHashMap<>();
/**
* Creates new {@link EventBus} instance.
* If you want to use global event bus go with singleton method instead.
*/
public EventBus() {
// pass
}
/**
* Subscribe to an event type.
*/
public <E extends ConnectorEvent> void subscribe(Class<? extends E> eventType, Consumer<E> subscriber) {
Set<Consumer<?>> eventSubscribers = getOrCreateSubscribers(eventType);
eventSubscribers.add(subscriber);
}
/**
* Unsubscribe from all event types.
*/
public <E extends ConnectorEvent> void unsubscribe(Consumer<E> subscriber) {
subscribers.values().forEach(eventSubscribers -> eventSubscribers.remove(subscriber));
}
/**
* Publish an event to all subscribers. The event is published to all consumers
* which subscribed to this event type or any super class.
*/
@SuppressWarnings("unchecked")
public <E extends ConnectorEvent> void fire(E event) {
Class<?> eventType = event.getClass();
subscribers.keySet().stream()
.filter(type -> type.isAssignableFrom(eventType))
.flatMap(type -> subscribers.get(type).stream())
.forEach(subscriber -> fire(event, (Consumer<E>) subscriber));
}
///////////////////////////////////////////////////////////////////////////
private <E> Set<Consumer<?>> getOrCreateSubscribers(Class<E> eventType) {
return subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArraySet<>());
}
private <E extends ConnectorEvent> void fire(E event, Consumer<E> subscriber) {
try {
if (Platform.isFxApplicationThread()) {
subscriber.accept(event);
} else {
LOGGER.log(Level.WARNING, "Calling the event bus not from the FX thread");
Platform.runLater(() -> subscriber.accept(event));
}
} catch (Exception e) {
LOGGER.log(Level.ERROR, e.getMessage());
}
}
}

View File

@ -0,0 +1,18 @@
package devtoolsfx.event;
import org.jspecify.annotations.NullMarked;
/**
* Refers to the source window that emitted an event.
*
* @param application the name of the application
* @param uid the ID of the window (stage)
* @param isPrimaryStage true if the source window is the primary stage; false otherwise
*/
@NullMarked
public record EventSource(String application, int uid, boolean isPrimaryStage) {
public String toLogString() {
return application + "#" + uid;
}
}

View File

@ -0,0 +1,44 @@
package devtoolsfx.event;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* Notifies about an exception that occurred during monitoring.
*
* @param eventSource the source of the event
* @param className the name of the exception class
* @param stackTrace the stack trace of the exception
* @param message the exception message
*/
@NullMarked
public record ExceptionEvent(EventSource eventSource,
String className,
String stackTrace,
@Nullable String message) implements ConnectorEvent {
private static final StringWriter STRING_WRITER = new StringWriter();
private static final PrintWriter PRINT_WRITER = new PrintWriter(STRING_WRITER);
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + className
+ " | message=" + message;
}
public static ExceptionEvent of(EventSource eventSource, Exception exception) {
PRINT_WRITER.flush();
exception.printStackTrace(PRINT_WRITER);
return new ExceptionEvent(
eventSource,
exception.getClass().getSimpleName(),
STRING_WRITER.toString(),
exception.getMessage()
);
}
}

View File

@ -0,0 +1,33 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import javafx.event.Event;
import javafx.event.EventType;
import org.jspecify.annotations.NullMarked;
/**
* A wrapper for {@link javafx.event.Event} to dispatch JavaFX events via the connector event bus.
*
* @param eventSource the source of the event
* @param element the node (element) that triggered the event
* @param eventType the type of JavaFX event
* @param value the event as a string, or any other payload in a human-readable format
*/
@NullMarked
public record JavaFXEvent(EventSource eventSource,
Element element,
EventType<? extends Event> eventType,
String value) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | type=" + eventType
+ " | value=" + value;
}
}

View File

@ -0,0 +1,43 @@
package devtoolsfx.event;
import devtoolsfx.connector.LocalElement;
import devtoolsfx.scenegraph.Element;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about changes in the mouse position.
* The position should (and must) be given in the scene's coordinates (not in local node coordinates).
*
* @param eventSource the event source
* @param element the element whose mouse position has changed
* @param x the x coordinate
* @param y the y coordinate
*/
@NullMarked
public record MousePosEvent(EventSource eventSource,
Element element,
double x,
double y) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | x=" + x + " y=" + y;
}
public static MousePosEvent of(EventSource eventSource, MouseEvent mouseEvent) {
return new MousePosEvent(eventSource,
LocalElement.of((Node) mouseEvent.getSource()),
mouseEvent.getSceneX(),
mouseEvent.getSceneY()
);
}
}

View File

@ -0,0 +1,31 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about the added scene graph node element.
*
* @param eventSource the event source
* @param element the element used to access the properties of the added node
*/
@NullMarked
public record NodeAddedEvent(EventSource eventSource,
Element element) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | properties=" + (element.isWindowElement() ? element.getWindowProperties() : element.getNodeProperties());
}
public static NodeAddedEvent of(EventSource eventSource, Element element) {
return new NodeAddedEvent(eventSource, element);
}
}

View File

@ -0,0 +1,27 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about the removed scene graph node element.
*
* @param eventSource the event source
* @param element the element used to access the properties of the removed node
*/
@NullMarked
public record NodeRemovedEvent(EventSource eventSource,
Element element) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | properties=" + (element.isWindowElement() ? element.getWindowProperties() : element.getNodeProperties());
}
}

View File

@ -0,0 +1,29 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about the selected scene graph node element.
* Selection is not something that is supported by the scene graph API, but an
* abstraction for the currently inspected node.
*
* @param eventSource the event source
* @param element the element used to access the properties of the selected node
*/
@NullMarked
public record NodeSelectedEvent(EventSource eventSource,
Element element) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | properties=" + (element.isWindowElement() ? element.getWindowProperties() : element.getNodeProperties());
}
}

View File

@ -0,0 +1,25 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
import java.util.List;
/**
* Notifies about changes to the node's style class list.
*
* @param eventSource the source of the event
* @param element the element whose style class list has changed
* @param styleClass the new style class list
*/
@NullMarked
public record NodeStyleClassEvent(EventSource eventSource,
Element element,
List<String> styleClass) implements ConnectorEvent {
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | styleClass=" + styleClass;
}
}

View File

@ -0,0 +1,24 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about node visibility state changes.
*
* @param eventSource the source of the event
* @param element the element whose visibility state has changed
* @param visible the new visibility state
*/
@NullMarked
public record NodeVisibilityEvent(EventSource eventSource,
Element element,
boolean visible) implements ConnectorEvent {
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | visible=" + visible;
}
}

View File

@ -0,0 +1,28 @@
package devtoolsfx.event;
import devtoolsfx.scenegraph.Element;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about changes to the window's scene or the scene's root,
* including situations when a new window (and thus a new scene and root) is added.
*
* @param eventSource the event source
* @param element the element used to access the properties of the scene's root node
*/
@NullMarked
public record RootChangedEvent(EventSource eventSource,
Element element) implements ConnectorEvent, ElementEvent {
@Override
public Element getElement() {
return element;
}
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | class=" + element.getSimpleClassName()
+ " | properties=" + (element.isWindowElement() ? element.getWindowProperties() : element.getNodeProperties());
}
}

View File

@ -0,0 +1,17 @@
package devtoolsfx.event;
import org.jspecify.annotations.NullMarked;
/**
* Notifies that the window has been closed.
*
* @param eventSource the event source
*/
@NullMarked
public record WindowClosedEvent(EventSource eventSource) implements ConnectorEvent {
@Override
public String toLogString() {
return "source=" + eventSource.toLogString();
}
}

View File

@ -0,0 +1,38 @@
package devtoolsfx.event;
import javafx.geometry.Dimension2D;
import javafx.geometry.Point2D;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
/**
* Notifies about changes to the window properties.
*
* @param eventSource the event source
* @param position the window's position on the screen
* @param size the window's size
* @param focused whether the window is focused
*/
@NullMarked
public record WindowPropertiesEvent(EventSource eventSource,
Point2D position,
Dimension2D size,
boolean focused) implements ConnectorEvent {
@Override
public String toLogString() {
return "source=" + eventSource.toLogString()
+ " | x=" + position.getX() + " y=" + position.getY()
+ " | width=" + size.getWidth() + " height=" + size.getHeight()
+ " | focused=" + focused;
}
public static WindowPropertiesEvent of(EventSource eventSource, Window window) {
return new WindowPropertiesEvent(
eventSource,
new Point2D(window.getX(), window.getY()),
new Dimension2D(window.getWidth(), window.getHeight()),
window.isFocused()
);
}
}

View File

@ -0,0 +1,9 @@
package devtoolsfx.scenegraph;
/**
* Represents the short information about an arbitrary {@link Class} name.
*/
public record ClassInfo(String module,
String className,
String simpleClassName) {
}

View File

@ -0,0 +1,87 @@
package devtoolsfx.scenegraph;
import java.util.List;
import javafx.scene.Node;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* An element represents an arbitrary item in the scene graph hierarchy
* and essentially serves as a local or remote wrapper around {@link Node}
* or {@link Window}.
* <p>
* While it is not directly enforced, an implementation must override
* {@code hashCode()} and {@code equals()} to function properly.
*/
@NullMarked
public interface Element {
/**
* Returns the unique element ID (generally, its hash code).
*/
int getUID();
/**
* Returns full information about the type of the wrapped scene graph node.
*/
ClassInfo getClassInfo();
/**
* Returns the parent element, or null if this element is the root.
*/
@Nullable
Element getParent();
/**
* Returns a list of the children of this element.
*/
List<Element> getChildren();
/**
* Checks whether this element has any children.
*/
boolean hasChildren();
/**
* Returns the properties of the wrapped node. If the element does not wrap
* a {@link Node} but a {@link Window}, it returns null.
*/
@Nullable
NodeProperties getNodeProperties();
/**
* Returns the properties of the wrapped window. If the element does not wrap
* a {@link Window} but a {@link Node}, it returns null.
*/
@Nullable
WindowProperties getWindowProperties();
/**
* Checks whether the element is a wrapper around {@link Node}.
*/
boolean isNodeElement();
/**
* Checks whether the element is a wrapper around {@link Window}.
*/
boolean isWindowElement();
/**
* Returns whether the element wraps an auxiliary node.
*/
default boolean isAuxiliaryElement() {
// all auxiliary nodes must have an ID that starts with a common prefix,
// for normal javaFX nodes ID isn't mandatory (and rarely used)
var props = getNodeProperties();
return props != null && props.isAuxiliaryElement();
}
/**
* Returns the simple class name of the wrapped scene graph node.
*/
default String getSimpleClassName() {
return getClassInfo().simpleClassName();
}
}

View File

@ -0,0 +1,54 @@
package devtoolsfx.scenegraph;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.util.SceneUtils;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.layout.Pane;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
import java.util.List;
/**
* Represents a selective set of node properties.
*
* @param id the {@link Node#getId()} of the node
* @param styleClass the {@link Node#getStyleClass()} of the node
* @param stylesheets the list of stylesheets for the node
* @param isControl whether the node is a JavaFX {@link Control}
* @param isPane whether the node is a JavaFX {@link Pane} or {@link Group}
* @param isVisible whether the node is visible
*/
@NullMarked
public record NodeProperties(@Nullable String id,
List<String> styleClass,
List<String> stylesheets,
@Nullable String userAgentStylesheet,
boolean isControl,
boolean isPane,
boolean isVisible) {
/**
* See {@link Element#isAuxiliaryElement()}.
*/
public boolean isAuxiliaryElement() {
// all auxiliary nodes must have an ID that starts with a common prefix,
// for normal javaFX nodes ID isn't mandatory (and rarely used)
return id != null && id.startsWith(ConnectorOptions.AUX_NODE_ID_PREFIX);
}
public static NodeProperties of(Node node) {
return new NodeProperties(
node.getId(),
Collections.unmodifiableList(node.getStyleClass()),
SceneUtils.getStylesheets(node),
SceneUtils.getUserAgentStylesheet(node),
node instanceof Control,
node instanceof Pane || node instanceof Group,
node.isVisible()
);
}
}

View File

@ -0,0 +1,29 @@
package devtoolsfx.scenegraph;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* Represents a vertex in the scene graph tree.
*/
@NullMarked
public interface Vertex {
/**
* Returns the parent element, if any.
*/
@Nullable
Element getParent();
/**
* Returns the list of child elements.
*/
List<Element> getChildren();
/**
* Returns whether the vertex has any child elements.
*/
boolean hasChildren();
}

View File

@ -0,0 +1,73 @@
package devtoolsfx.scenegraph;
import javafx.scene.Scene;
import javafx.scene.control.DialogPane;
import javafx.stage.Modality;
import javafx.stage.PopupWindow;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
import java.util.List;
/**
* Represents a selective set of window properties.
*
* @param windowType the type of the window
* @param sceneStylesheets the list of stylesheets for the scene
* @param isPrimaryStage whether the window is the primary stage
* @param windowTitle the title of the window
* @param ownerClassName the class name of the window owner's node
*/
@NullMarked
public record WindowProperties(WindowType windowType,
List<String> sceneStylesheets,
@Nullable String userAgentStylesheet,
boolean isPrimaryStage,
@Nullable String windowTitle,
@Nullable String ownerClassName) {
public enum WindowType {
STAGE, MODAL, POPUP, ALERT
}
public static WindowProperties of(Window window, boolean isPrimaryStage) {
var type = WindowType.STAGE;
String windowTitle = null;
String ownerClassName = null;
Scene scene = window.getScene();
if (window instanceof PopupWindow popup) {
type = WindowType.POPUP;
if (popup.getOwnerNode() != null) {
ownerClassName = popup.getOwnerNode().getClass().getSimpleName();
}
}
if (window instanceof Stage stage) {
if (stage.getModality() == Modality.WINDOW_MODAL || stage.getModality() == Modality.APPLICATION_MODAL) {
type = WindowType.MODAL;
}
// alert can be modal or not modal
if (scene != null && scene.getRoot() instanceof DialogPane) {
type = WindowType.ALERT;
}
windowTitle = stage.getTitle();
}
List<String> stylesheets = window.getScene() != null
? Collections.unmodifiableList(window.getScene().getStylesheets())
: List.of();
String uas = null;
if (scene != null && scene.getUserAgentStylesheet() != null && !scene.getUserAgentStylesheet().isEmpty()) {
uas = scene.getUserAgentStylesheet();
}
return new WindowProperties(type, stylesheets, uas, isPrimaryStage, windowTitle, ownerClassName);
}
}

View File

@ -0,0 +1,166 @@
package devtoolsfx.scenegraph.attributes;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.effect.Effect;
import javafx.scene.image.Image;
import javafx.scene.layout.Background;
import javafx.scene.layout.Border;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.RowConstraints;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.transform.Transform;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Map;
/**
* Encapsulates summary information about an observable object property or field.
*
* @param name the unique attribute name (label)
* @param value the value of a property, field, or any other payload
* @param field the observable property or field name, can be null
* @param cssProperty the corresponding CSS property name, if styleable
* @param observableType the type of the observable property
* @param displayHint a hint to assist in displaying the property value
* @param valueState whether the property value has been changed
* @param validValues the set (or range) of valid property values
*/
@NullMarked
public record Attribute<V>(
String name,
@Nullable V value,
@Nullable String field,
@Nullable String cssProperty,
ObservableType observableType,
DisplayHint displayHint,
ValueState valueState,
List<V> validValues) {
public Attribute(String name,
@Nullable V value,
@Nullable String field,
ObservableType observableType,
DisplayHint displayHint,
ValueState valueState) {
this(name, value, field, null, observableType, displayHint, valueState, List.of());
}
public Attribute(String name,
@Nullable V value,
@Nullable String field,
@Nullable String cssProperty,
ObservableType observableType,
DisplayHint displayHint,
ValueState valueState) {
this(name, value, field, cssProperty, observableType, displayHint, valueState, List.of());
}
@Override
public boolean equals(Object target) {
if (this == target) {
return true;
}
if (target == null || getClass() != target.getClass()) {
return false;
}
Attribute<?> attribute = (Attribute<?>) target;
return name.equals(attribute.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
public String toLogString() {
return "Attribute:"
+ " name=" + name
+ " value=" + value
+ " field=" + field
+ " valueState=" + valueState;
}
///////////////////////////////////////////////////////////////////////////
/**
* Provides a hint for displaying the attribute value.
*/
public enum DisplayHint {
BACKGROUND(Background.class),
BOOLEAN(Boolean.class),
BORDER(Border.class),
BOUNDS(Bounds.class),
CLIP(Clip.class),
COLOR(Color.class),
COLUMN_CONSTRAINTS(ColumnConstraints.class),
EFFECT(Effect.class),
ENUM(Enum.class),
FONT(Font.class),
IMAGE(Image.class),
INSETS(Insets.class),
NUMERIC(Double.class),
OBJECT(Object.class),
PROPERTIES(Map.class),
ROW_CONSTRAINTS(RowConstraints.class),
TEXT(String.class),
TRANSFORMS(Transform.class);
private final Class<?> valueType;
DisplayHint(Class<?> valueType) {
this.valueType = valueType;
}
public Class<?> valueType() {
return valueType;
}
}
/**
* Represents the observable type of property.
*/
public enum ObservableType {
READ_WRITE,
READ_ONLY,
BOUND,
LIST,
SET,
NOT_OBSERVABLE;
public static <T extends ObservableValue<?>> ObservableType of(T prop) {
if (prop instanceof Property<?> p && p.isBound()) {
return ObservableType.BOUND;
}
if (prop.getClass().getSimpleName().startsWith("ReadOnly")) {
return READ_ONLY;
}
return READ_WRITE;
}
}
/**
* Represents the state of an attribute value.
* <li>DEFAULT - indicates that the attribute value is in its default state</li>
* <li>CHANGED - indicates that the attribute value has been modified</li>
* <li>AUTO - indicates that the attribute value is auto-computed or constant</li>
*/
public enum ValueState {
DEFAULT,
CHANGED,
AUTO;
public static ValueState defaultIf(boolean state) {
return state ? DEFAULT : CHANGED;
}
}
}

View File

@ -0,0 +1,42 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
/**
* The attribute category corresponds to {@link Tracker} implementations;
* each of these is designed to work with one of the categories.
*/
public enum AttributeCategory {
CONTROL,
GRID_PANE,
LABELED,
IMAGE_VIEW,
NODE,
PARENT,
REFLECTIVE,
REGION,
SCENE,
SHAPE,
TEXT,
WINDOW;
public static Tracker createTracker(AttributeCategory category,
EventBus eventBus,
EventSource eventSource) {
return switch (category) {
case CONTROL -> new ControlTracker(eventBus, eventSource);
case GRID_PANE -> new GridPaneTracker(eventBus, eventSource);
case LABELED -> new LabeledTracker(eventBus, eventSource);
case IMAGE_VIEW -> new ImageViewTracker(eventBus, eventSource);
case NODE -> new NodeTracker(eventBus, eventSource);
case PARENT -> new ParentTracker(eventBus, eventSource);
case REFLECTIVE -> new ReflectiveTracker(eventBus, eventSource);
case REGION -> new RegionTracker(eventBus, eventSource);
case SCENE -> new SceneTracker(eventBus, eventSource);
case SHAPE -> new ShapeTracker(eventBus, eventSource);
case TEXT -> new TextTracker(eventBus, eventSource);
case WINDOW -> new WindowTracker(eventBus, eventSource);
};
}
}

View File

@ -0,0 +1,10 @@
package devtoolsfx.scenegraph.attributes;
import javafx.geometry.Bounds;
import javafx.scene.Node;
/**
* A wrapper to transfer {@link Node#clipProperty()} info.
*/
public record Clip(String className, Bounds bounds) {
}

View File

@ -0,0 +1,127 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.scene.control.Control;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link Control} class.
*/
@NullMarked
public final class ControlTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"skin", "minWidth", "minHeight", "prefWidth", "prefHeight", "maxWidth", "maxHeight",
"stylesheets", "userAgentStylesheet"
);
public ControlTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.CONTROL);
}
@Override
public void reload(String... properties) {
Control control = (Control) getTarget();
if (control == null) {
return;
}
reload(property -> read(control, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Control;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Control control, String property) {
return switch (property) {
case "skin" -> new Attribute<>(
"skin",
control.getSkin().getClass().getCanonicalName(),
"skin",
"-fx-skin",
ObservableType.of(control.skinProperty()),
DisplayHint.TEXT,
ValueState.AUTO
);
case "minWidth" -> new Attribute<>(
"minWidth",
control.getMinWidth(),
"minWidthProperty",
ObservableType.of(control.minWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getMinWidth() == Control.USE_COMPUTED_SIZE)
);
case "minHeight" -> new Attribute<>(
"minHeight",
control.getMinHeight(),
"minHeightProperty",
ObservableType.of(control.minHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getMinHeight() == Control.USE_COMPUTED_SIZE)
);
case "prefWidth" -> new Attribute<>(
"prefWidth",
control.getPrefWidth(),
"prefWidthProperty",
ObservableType.of(control.prefWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getPrefWidth() == Control.USE_COMPUTED_SIZE)
);
case "prefHeight" -> new Attribute<>(
"prefHeight",
control.getPrefHeight(),
"prefHeightProperty",
ObservableType.of(control.prefHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getPrefHeight() == Control.USE_COMPUTED_SIZE)
);
case "maxWidth" -> new Attribute<>(
"maxWidth",
control.getMaxWidth(),
"maxWidthProperty",
ObservableType.of(control.maxWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getMaxWidth() == Control.USE_COMPUTED_SIZE)
);
case "maxHeight" -> new Attribute<>(
"maxHeight",
control.getMaxHeight(),
"maxHeightProperty",
ObservableType.of(control.maxHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(control.getMaxHeight() == Control.USE_COMPUTED_SIZE)
);
case "stylesheets" -> {
String stylesheets = String.join("\n", control.getStylesheets());
yield new Attribute<>(
"stylesheets",
stylesheets,
"getStylesheets",
ObservableType.LIST,
DisplayHint.TEXT,
ValueState.defaultIf(control.getStylesheets().isEmpty())
);
}
case "userAgentStylesheet" -> new Attribute<>(
"userAgentStylesheet",
control.getUserAgentStylesheet(),
"userAgentStylesheet",
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.defaultIf(control.getUserAgentStylesheet() == null)
);
default -> null;
};
}
}

View File

@ -0,0 +1,133 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.collections.ListChangeListener;
import javafx.geometry.Pos;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.RowConstraints;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link GridPane} class.
*/
@NullMarked
public final class GridPaneTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"hgap", "vgap", "alignment", "gridLinesVisible", "rowConstrains", "columnConstraints"
);
private final ListChangeListener<RowConstraints> rowListener = c -> reload("rowConstrains");
private final ListChangeListener<ColumnConstraints> colListener = c -> reload("columnConstraints");
public GridPaneTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.GRID_PANE);
}
@Override
public void reload(String... properties) {
GridPane gridpane = (GridPane) getTarget();
if (gridpane == null) {
return;
}
reload(property -> read(gridpane, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof GridPane;
}
@Override
public void setTarget(@Nullable Object target) {
if (getTarget() != null) {
GridPane old = (GridPane) getTarget();
old.getRowConstraints().removeListener(rowListener);
old.getColumnConstraints().removeListener(colListener);
}
super.setTarget(target);
GridPane grid = (GridPane) target;
if (grid != null) {
grid.getRowConstraints().addListener(rowListener);
grid.getColumnConstraints().addListener(colListener);
}
}
@Override
protected void beforeResetTarget(Object target) {
GridPane grid = (GridPane) target;
grid.getRowConstraints().removeListener(rowListener);
grid.getColumnConstraints().removeListener(colListener);
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(GridPane gridpane, String property) {
return switch (property) {
case "hgap" -> new Attribute<>(
"hgap",
gridpane.getHgap(),
"hgapProperty",
"-fx-hgap",
ObservableType.of(gridpane.hgapProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(gridpane.getHgap() == 0)
);
case "vgap" -> new Attribute<>(
"vgap",
gridpane.getVgap(),
"vgapProperty",
"-fx-vgap",
ObservableType.of(gridpane.vgapProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(gridpane.getVgap() == 0)
);
case "alignment" -> new Attribute<>(
"alignment",
gridpane.getAlignment(),
"alignmentProperty",
"-fx-alignment",
ObservableType.of(gridpane.alignmentProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(gridpane.getAlignment() == null || gridpane.getAlignment() == Pos.TOP_LEFT)
);
case "gridLinesVisible" -> new Attribute<>(
"gridLinesVisible",
gridpane.isGridLinesVisible(),
"gridLinesVisibleProperty",
"-fx-grid-lines-visible",
ObservableType.of(gridpane.gridLinesVisibleProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!gridpane.isGridLinesVisible())
);
case "rowConstrains" -> new Attribute<>(
"rowConstrains",
Collections.unmodifiableList(gridpane.getRowConstraints()),
"getRowConstraints",
ObservableType.LIST,
DisplayHint.ROW_CONSTRAINTS,
ValueState.defaultIf(gridpane.getRowConstraints().isEmpty())
);
case "columnConstraints" -> new Attribute<>(
"columnConstraints",
Collections.unmodifiableList(gridpane.getColumnConstraints()),
"getColumnConstraints",
ObservableType.LIST,
DisplayHint.COLUMN_CONSTRAINTS,
ValueState.defaultIf(gridpane.getColumnConstraints().isEmpty())
);
default -> null;
};
}
}

View File

@ -0,0 +1,101 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.scene.image.ImageView;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Objects;
/**
* The {@link Tracker} implementation for the {@link ImageView} class.
*/
@NullMarked
public final class ImageViewTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"fitWidth", "fitHeight", "image", "preserveRatio", "smooth"
);
public ImageViewTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.IMAGE_VIEW);
}
@Override
public void reload(String... properties) {
ImageView imageView = (ImageView) getTarget();
if (imageView == null) {
return;
}
reload(property -> read(imageView, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof ImageView;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(ImageView imageView, String property) {
return switch (property) {
case "fitWidth" -> new Attribute<>(
"fitWidth",
imageView.getFitWidth(),
"fitWidthProperty",
"-fx-fit-width",
ObservableType.of(imageView.fitWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(imageView.getFitWidth() == 0)
);
case "fitHeight" -> new Attribute<>(
"fitHeight",
imageView.getFitHeight(),
"fitHeightProperty",
"-fx-fit-height",
ObservableType.of(imageView.fitHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(imageView.getFitWidth() == 0)
);
case "image" -> {
var image = imageView.getImage();
yield new Attribute<>(
"image",
image != null
? Objects.requireNonNullElse(image.getUrl(), String.valueOf(image))
: null,
"imageProperty",
"-fx-image",
ObservableType.of(imageView.imageProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(image == null)
);
}
case "preserveRatio" -> new Attribute<>(
"preserveRatio",
imageView.isPreserveRatio(),
"preserveRatioProperty",
"-fx-preserve-ratio",
ObservableType.of(imageView.preserveRatioProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!imageView.isPreserveRatio())
);
case "smooth" -> new Attribute<>(
"smooth",
imageView.isSmooth(),
"smoothProperty",
"-fx-smooth",
ObservableType.of(imageView.smoothProperty()),
DisplayHint.BOOLEAN,
ValueState.AUTO // platform-dependent, no API
);
default -> null;
};
}
}

View File

@ -0,0 +1,178 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Labeled;
import javafx.scene.control.OverrunStyle;
import javafx.scene.paint.Color;
import javafx.scene.text.TextAlignment;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link Labeled} class.
*/
@NullMarked
public final class LabeledTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"text", "font", "textFill", "graphic", "graphicTextGap", "labelPadding",
"contentDisplay", "alignment", "textAlignment",
"textOverrun", "wrapText", "underline", "ellipsisString"
); // label padding, text fill, ellipsis-string
public LabeledTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.LABELED);
}
@Override
public void reload(String... properties) {
Labeled labeled = (Labeled) getTarget();
if (labeled == null) {
return;
}
reload(property -> read(labeled, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Labeled;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Labeled label, String property) {
return switch (property) {
case "text" -> new Attribute<>(
"text",
label.getText(),
"textProperty",
ObservableType.of(label.textProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(label.getText() == null || label.getText().isEmpty())
);
case "font" -> new Attribute<>(
"font",
label.getFont(),
"fontProperty",
"-fx-font",
ObservableType.of(label.fontProperty()),
DisplayHint.FONT,
ValueState.defaultIf(label.getFont() == null)
);
case "textFill" -> new Attribute<>(
"textFill",
label.getTextFill(),
"textFillProperty",
"-fx-text-fill",
ObservableType.of(label.textFillProperty()),
DisplayHint.COLOR,
ValueState.defaultIf(Color.BLACK.equals(label.getTextFill()))
);
case "graphic" -> new Attribute<>(
"graphic",
label.getGraphic() != null ? label.getGraphic().getClass().getSimpleName() : null,
"graphicProperty",
"-fx-graphic",
ObservableType.of(label.graphicProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(label.getGraphic() == null)
);
case "graphicTextGap" -> new Attribute<>(
"graphicTextGap",
label.getGraphicTextGap(),
"graphicTextGapProperty",
"-fx-graphic-text-gap",
ObservableType.of(label.graphicTextGapProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(label.getGraphicTextGap() == 4)
);
case "labelPadding" -> new Attribute<>(
"labelPadding",
label.getLabelPadding(),
"labelPaddingProperty",
"-fx-label-padding",
ObservableType.of(label.labelPaddingProperty()),
DisplayHint.INSETS,
ValueState.defaultIf(label.getLabelPadding() == null || Insets.EMPTY.equals(label.getLabelPadding()))
);
case "contentDisplay" -> new Attribute<>(
"contentDisplay",
label.getContentDisplay(),
"contentDisplayProperty",
"-fx-content-display",
ObservableType.of(label.contentDisplayProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(label.getContentDisplay() == null || label.getContentDisplay() == ContentDisplay.LEFT),
List.of(ContentDisplay.values())
);
case "alignment" -> new Attribute<>(
"alignment",
label.getAlignment(),
"alignmentProperty",
"-fx-alignment",
ObservableType.of(label.alignmentProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(label.getAlignment() == null || label.getAlignment() == Pos.CENTER_LEFT),
List.of(Pos.values())
);
case "textAlignment" -> new Attribute<>(
"textAlignment",
label.getTextAlignment(),
"textAlignmentProperty",
"-fx-text-alignment",
ObservableType.of(label.textAlignmentProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(label.getTextAlignment() == null || label.getTextAlignment() == TextAlignment.LEFT),
List.of(TextAlignment.values())
);
case "textOverrun" -> new Attribute<>(
"textOverrun",
label.getTextOverrun(),
"textOverrunProperty",
"-fx-text-overrun",
ObservableType.of(label.textOverrunProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(label.getTextOverrun() == null || label.getTextOverrun() == OverrunStyle.ELLIPSIS),
List.of(OverrunStyle.values())
);
case "wrapText" -> new Attribute<>(
"wrapText",
label.isWrapText(),
"wrapTextProperty",
"-fx-wrap-text",
ObservableType.of(label.wrapTextProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!label.isWrapText())
);
case "underline" -> new Attribute<>(
"underline",
label.isUnderline(),
"underlineProperty",
"-fx-underline",
ObservableType.of(label.underlineProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!label.isUnderline())
);
case "ellipsisString" -> new Attribute<>(
"ellipsisString",
label.getEllipsisString(),
"ellipsisStringProperty",
"-fx-ellipsis-string",
ObservableType.of(label.ellipsisStringProperty()),
DisplayHint.TEXT,
ValueState.defaultIf("...".equals(label.getEllipsisString()))
);
default -> null;
};
}
}

View File

@ -0,0 +1,500 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.collections.ListChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.SubScene;
import javafx.scene.effect.BlendMode;
import javafx.scene.transform.Transform;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
import static devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
/**
* The {@link Tracker} implementation for the {@link Node} class.
*/
@NullMarked
public final class NodeTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"className", "pseudoClass", "styleClass", "stylesheets",
"managed", "visible", "focusVisible", "focusWithin", "resizable",
"layoutBounds", "boundsInParent", "baselineOffset", "layoutConstraints",
"opacity", "viewOrder", "blendMode", "cursor", "effect", "clip", "rotate", "transforms",
"layoutX", "layoutY", "scaleX", "scaleY", "scaleZ", "translateX", "translateY", "translateZ",
"contentBias", "minWidth", "minHeight", "prefWidth", "prefHeight", "maxWidth", "maxHeight",
"userAgentStylesheet", "userData"
);
public static final List<String> SUB_SCENE_PROPERTIES = List.of("userAgentStylesheet");
private final ListChangeListener<Transform> transformListener = c -> reload("transforms");
public NodeTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.NODE);
}
@Override
public void reload(String... properties) {
Node node = (Node) getTarget();
if (node == null) {
return;
}
var supportedProperties = new ArrayList<>(SUPPORTED_PROPERTIES);
if (!(node instanceof SubScene)) {
supportedProperties.removeAll(SUB_SCENE_PROPERTIES);
}
reload(property -> read(node, property), supportedProperties, properties);
}
@Override
public void setTarget(@Nullable Object target) {
Node old = (Node) getTarget();
if (old != null) {
old.getTransforms().removeListener(transformListener);
}
super.setTarget(target);
if (target != null) {
((Node) target).getTransforms().addListener(transformListener);
}
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Node;
}
@Override
protected void beforeResetTarget(Object target) {
((Node) target).getTransforms().removeListener(transformListener);
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Node node, String property) {
var dimensions = Dimensions.of(node);
return switch (property) {
case "className" -> new Attribute<>(
"className",
node.getClass().getName(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.AUTO
);
case "pseudoClass" -> {
String pseudoClass = node.getPseudoClassStates().stream()
.map(PseudoClass::getPseudoClassName)
.collect(Collectors.joining(" "));
yield new Attribute<>(
"pseudoClass",
pseudoClass,
"getPseudoClassStates",
ObservableType.SET,
DisplayHint.TEXT,
ValueState.defaultIf(node.getPseudoClassStates().isEmpty())
);
}
case "styleClass" -> {
String styleClass = String.join(" ", node.getStyleClass());
yield new Attribute<>(
"styleClass",
styleClass,
"getStyleClass",
ObservableType.LIST,
DisplayHint.TEXT,
ValueState.defaultIf(node.getStyleClass().isEmpty())
);
}
case "visible" -> new Attribute<>(
"visible",
node.isVisible(),
"visibleProperty",
"visibility",
ObservableType.of(node.visibleProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(node.isVisible())
);
case "managed" -> new Attribute<>(
"managed",
node.isManaged(),
"managedProperty",
"-fx-managed",
ObservableType.of(node.managedProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(node.isManaged()),
List.of()
);
case "focusVisible" -> new Attribute<>(
"focusVisible",
node.isFocusVisible(),
"focusVisibleProperty",
ObservableType.READ_ONLY,
DisplayHint.BOOLEAN,
ValueState.defaultIf(!node.isFocusVisible())
);
case "focusWithin" -> new Attribute<>(
"focusWithin",
node.isFocusWithin(),
"focusWithinProperty",
ObservableType.READ_ONLY,
DisplayHint.BOOLEAN,
ValueState.defaultIf(!node.isFocusWithin())
);
case "resizable" -> new Attribute<>(
"resizable",
node.isResizable(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.BOOLEAN,
ValueState.AUTO
);
case "layoutBounds" -> new Attribute<>(
"layoutBounds",
node.getLayoutBounds(),
"layoutBoundsProperty",
ObservableType.READ_ONLY,
DisplayHint.BOUNDS,
ValueState.AUTO
);
case "boundsInParent" -> new Attribute<>(
"boundsInParent",
node.getBoundsInParent(),
"boundsInParentProperty",
ObservableType.READ_ONLY,
DisplayHint.BOUNDS,
ValueState.AUTO
);
case "baselineOffset" -> new Attribute<>(
"baselineOffset",
node.getBaselineOffset(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "layoutConstraints" -> {
Map<String, String> properties = getLayoutConstraints(node);
yield new Attribute<>(
"layoutConstraints",
properties,
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.PROPERTIES,
ValueState.AUTO
);
}
case "opacity" -> new Attribute<>(
"opacity",
node.getOpacity(),
"opacityProperty",
"-fx-opacity",
ObservableType.of(node.opacityProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getOpacity() == 1.0),
List.of(0.0, 1.0)
);
case "viewOrder" -> new Attribute<>(
"viewOrder",
node.getViewOrder(),
"viewOrderProperty",
"-fx-view-order",
ObservableType.of(node.viewOrderProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getViewOrder() == 0)
);
case "blendMode" -> new Attribute<>(
"blendMode",
node.getBlendMode(),
"blendModeProperty",
"-fx-blend-mode",
ObservableType.of(node.blendModeProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(node.getBlendMode() == null),
List.of(BlendMode.values())
);
case "cursor" -> new Attribute<>(
"cursor",
node.getCursor() != null ? String.valueOf(node.getCursor()) : null,
"cursorProperty",
"-fx-cursor",
ObservableType.of(node.cursorProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(node.getCursor() == null)
);
case "effect" -> new Attribute<>(
"effect",
node.getEffect(),
"effectProperty",
"-fx-effect",
ObservableType.of(node.effectProperty()),
DisplayHint.EFFECT,
ValueState.defaultIf(node.getEffect() == null)
);
case "clip" -> {
var clip = node.getClip();
yield new Attribute<>(
"clip",
clip != null ? new Clip(clip.getClass().getSimpleName(), clip.getBoundsInLocal()) : null,
"clipProperty",
ObservableType.of(node.clipProperty()),
DisplayHint.CLIP,
ValueState.defaultIf(node.getClip() == null)
);
}
case "rotate" -> new Attribute<>(
"rotate",
node.getRotate(),
"rotateProperty",
"-fx-rotate",
ObservableType.of(node.rotateProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getRotate() == 0)
);
case "transforms" -> new Attribute<>(
"transforms",
Collections.unmodifiableList(node.getTransforms()),
"getTransforms",
ObservableType.LIST,
DisplayHint.TRANSFORMS,
ValueState.defaultIf(node.getTransforms().isEmpty())
);
case "layoutX" -> new Attribute<>(
"layoutX",
node.getLayoutX(),
"layoutXProperty",
ObservableType.of(node.layoutXProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "layoutY" -> new Attribute<>(
"layoutY",
node.getLayoutY(),
"layoutYProperty",
ObservableType.of(node.layoutYProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "scaleX" -> new Attribute<>(
"scaleX",
node.getScaleX(),
"scaleXProperty",
"-fx-scale-x",
ObservableType.of(node.scaleXProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getScaleX() == 1.0)
);
case "scaleY" -> new Attribute<>(
"scaleY",
node.getScaleY(),
"scaleYProperty",
"-fx-scale-y",
ObservableType.of(node.scaleYProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getScaleY() == 1.0)
);
case "scaleZ" -> new Attribute<>(
"scaleZ",
node.getScaleZ(),
"scaleZProperty",
"-fx-scale-z",
ObservableType.of(node.scaleZProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getScaleZ() == 1.0)
);
case "translateX" -> new Attribute<>(
"translateX",
node.getTranslateX(),
"translateXProperty",
"-fx-translate-x",
ObservableType.of(node.translateXProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getTranslateX() == 0)
);
case "translateY" -> new Attribute<>(
"translateY",
node.getTranslateY(),
"translateYProperty",
"-fx-translate-y",
ObservableType.of(node.translateYProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getTranslateY() == 0)
);
case "translateZ" -> new Attribute<>(
"translateZ",
node.getTranslateZ(),
"translateZProperty",
"-fx-translate-z",
ObservableType.of(node.translateZProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(node.getTranslateZ() == 0)
);
case "contentBias" -> new Attribute<>(
"contentBias",
node.getContentBias(),
null,
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.ENUM,
ValueState.defaultIf(node.getContentBias() == null),
List.of(Orientation.values())
);
case "minWidth" -> new Attribute<>(
"minWidth",
dimensions.minWidth(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "minHeight" -> new Attribute<>(
"minHeight",
dimensions.minHeight(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "prefWidth" -> new Attribute<>(
"prefWidth",
dimensions.prefWidth(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "prefHeight" -> new Attribute<>(
"prefHeight",
dimensions.prefHeight(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "maxWidth" -> new Attribute<>(
"maxWidth",
dimensions.maxWidth(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "maxHeight" -> new Attribute<>(
"maxHeight",
dimensions.maxHeight(),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "userData" -> new Attribute<>(
"userData",
String.valueOf(node.getUserData()),
"userData",
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.defaultIf(node.getUserData() == null)
);
// SubScene
case "stylesheets" -> {
if (node instanceof SubScene subScene) {
yield new Attribute<>(
"userAgentStylesheet",
subScene.getUserAgentStylesheet(),
"userAgentStylesheet",
ObservableType.of(subScene.userAgentStylesheetProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(subScene.getUserAgentStylesheet() == null)
);
} else {
yield null;
}
}
default -> null;
};
}
/**
* Attempts to obtain layout constraints from the full node properties map.
* See {@link Node#getProperties()} for more details.
*/
private Map<String, String> getLayoutConstraints(Node node) {
if (!node.hasProperties()) {
return Map.of();
}
Map<String, String> properties = new TreeMap<>();
for (var entry : node.getProperties().entrySet()) {
if (entry.getKey() instanceof String key && (key.contains("pane-") || key.contains("box-"))) {
var value = entry.getValue();
if (key.endsWith("margin")) {
properties.put(key, insetsToString((Insets) value));
} else {
properties.put(key, String.valueOf(value));
}
}
}
return properties;
}
private String insetsToString(Insets insets) {
return insets.getTop() + " " + insets.getRight() + " " + insets.getBottom() + " " + insets.getLeft();
}
/**
* Abstracts the size calculation logic based on the value of {@link Node#getContentBias()}.
*/
private record Dimensions(double minWidth,
double minHeight,
double prefWidth,
double prefHeight,
double maxWidth,
double maxHeight) {
public static Dimensions of(Node node) {
double minWidth, minHeight, prefWidth, prefHeight, maxWidth, maxHeight;
switch (node.getContentBias()) {
case HORIZONTAL -> {
minWidth = node.minWidth(-1);
minHeight = node.minHeight(minWidth);
prefWidth = node.prefWidth(-1);
prefHeight = node.prefHeight(prefWidth);
maxWidth = node.maxWidth(-1);
maxHeight = node.maxHeight(maxWidth);
}
case VERTICAL -> {
minHeight = node.minHeight(-1);
minWidth = node.minWidth(minHeight);
prefHeight = node.prefHeight(-1);
prefWidth = node.prefWidth(prefHeight);
maxHeight = node.maxHeight(-1);
maxWidth = node.maxWidth(maxHeight);
}
case null -> {
minWidth = node.minWidth(-1);
minHeight = node.minHeight(-1);
prefWidth = node.prefWidth(-1);
prefHeight = node.prefHeight(-1);
maxWidth = node.maxWidth(-1);
maxHeight = node.maxHeight(-1);
}
}
return new Dimensions(minWidth, minHeight, prefWidth, prefHeight, maxWidth, maxHeight);
}
}
}

View File

@ -0,0 +1,86 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import devtoolsfx.util.SceneUtils;
import javafx.scene.Parent;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link Parent} class.
*/
@NullMarked
public final class ParentTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"stylesheets", "needsLayout", "childCount", "branchCount"
);
public ParentTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.PARENT);
}
@Override
public void reload(String... properties) {
Parent parent = (Parent) getTarget();
if (parent == null) {
return;
}
reload(property -> read(parent, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Parent;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Parent parent, String property) {
return switch (property) {
case "stylesheets" -> {
String stylesheets = String.join("\n", parent.getStylesheets());
yield new Attribute<>(
"stylesheets",
stylesheets,
"getStylesheets",
ObservableType.LIST,
DisplayHint.TEXT,
ValueState.AUTO
);
}
case "needsLayout" -> new Attribute<>(
"needsLayout",
parent.isNeedsLayout(),
"needsLayoutProperty",
ObservableType.READ_ONLY,
DisplayHint.BOOLEAN,
ValueState.AUTO
);
case "childCount" -> new Attribute<>(
"childCount",
SceneUtils.countChildren(parent),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "branchCount" -> new Attribute<>(
"branchCount",
SceneUtils.countNodesInBranch(parent),
null,
ObservableType.NOT_OBSERVABLE,
DisplayHint.NUMERIC,
ValueState.AUTO
);
default -> null;
};
}
}

View File

@ -0,0 +1,82 @@
package devtoolsfx.scenegraph.attributes;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ObservableValue;
import org.jspecify.annotations.NullMarked;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* The property listener accepts the target node, reflectively scans all its
* methods that return observable properties (which must end with the 'Property' suffix),
* and listens for changes to all found properties.
* <p>
* When a change is detected, the {@link #onPropertyChanged} method is called. To use this class,
* the client should implement this abstract method and include the desired logic within it.
*/
@NullMarked
public abstract class PropertyListener {
public static final String PROPERTY_SUFFIX = "Property";
private final Map<ObservableValue<?>, String> properties = new HashMap<>();
private final InvalidationListener propertyListener = obs ->
onPropertyChanged(properties.get((ObservableValue<?>) obs), (ObservableValue<?>) obs);
public PropertyListener() {
// pass
}
/**
* This method is called when an observable property change is detected.
*/
protected abstract void onPropertyChanged(String propertyName, ObservableValue<?> obs);
/**
* Returns the map of observable properties that have been found and are being tracked.
*/
public Map<ObservableValue<?>, String> getProperties() {
return Collections.unmodifiableMap(properties);
}
/**
* Sets the target to be tracked for property changes.
*/
public void use(Object target) throws InvocationTargetException, IllegalAccessException {
properties.clear();
// using reflection, locate all properties and their corresponding property references
for (Method method : target.getClass().getMethods()) {
if (!method.getName().endsWith(PROPERTY_SUFFIX)) {
continue;
}
Class<?> returnType = method.getReturnType();
if (ObservableValue.class.isAssignableFrom(returnType)) {
method.setAccessible(true);
ObservableValue<?> property = (ObservableValue<?>) method.invoke(target);
String propertyName = method.getName().substring(0, method.getName().lastIndexOf(PROPERTY_SUFFIX));
properties.put(property, propertyName);
}
}
for (ObservableValue<?> obs : properties.keySet()) {
obs.addListener(propertyListener);
}
}
/**
* Stops the listener from tracking property changes.
*/
public void release() {
for (ObservableValue<?> obs : properties.keySet()) {
obs.removeListener(propertyListener);
}
properties.clear();
}
}

View File

@ -0,0 +1,206 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WritableValue;
import javafx.css.CssMetaData;
import javafx.css.StyleableProperty;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.layout.Background;
import javafx.scene.layout.Border;
import javafx.scene.paint.Color;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* The {@link Tracker} implementation that attempts to reflectively obtain all
* the properties of the target node.
*/
@NullMarked
public final class ReflectiveTracker extends Tracker {
private final Map<String, ObservableValue<?>> orderedProperties = new TreeMap<>();
private final Map<WritableValue<?>, String> styleableProperties = new HashMap<>();
public ReflectiveTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.REFLECTIVE);
}
@Override
public void reload(String... properties) {
Object node = getTarget();
if (node == null) {
return;
}
reload(this::read, orderedProperties.keySet(), properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target != null;
}
@Override
protected void beforeSetTarget(Object target) {
scan(target);
}
@Override
protected void beforeResetTarget(Object target) {
orderedProperties.clear();
styleableProperties.clear();
}
///////////////////////////////////////////////////////////////////////////
@SuppressWarnings({"rawtypes", "unchecked", "ConstantValue"})
private void scan(Object target) {
orderedProperties.clear();
for (Map.Entry<ObservableValue<?>, String> entry : propertyListener.getProperties().entrySet()) {
// should never happen, but double check there no null values in observed properties
if (entry.getKey() != null && entry.getValue() != null) {
orderedProperties.put(entry.getValue(), entry.getKey());
}
}
styleableProperties.clear();
if (target instanceof Node node) {
for (CssMetaData meta : node.getCssMetaData()) {
StyleableProperty<?> styleable = meta.getStyleableProperty(node);
String name = meta.getProperty();
if (styleable != null && name != null) {
styleableProperties.put(styleable, name);
}
}
}
}
private Attribute<?> read(String property) {
ObservableValue<?> obs = orderedProperties.get(property);
Object value = obs.getValue(); // can be null and when it's null we won't get any DisplayHint...
ObservableType obsType = ObservableType.of(obs);
String field = property + PropertyListener.PROPERTY_SUFFIX;
String styleable = obs instanceof WritableValue<?> writable ? styleableProperties.get(writable) : null;
// ValueState.NOT_APPLICABLE everywhere, because it's not possible to guess
// whether the observable property has default value or not
return switch (value) {
// primitive properties
case Boolean bool -> new Attribute<>(
property,
bool,
field,
styleable,
obsType,
DisplayHint.BOOLEAN,
ValueState.AUTO
);
case Integer number -> new Attribute<>(
property,
number,
field,
styleable,
obsType,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case Double number -> new Attribute<>(
property,
number,
field,
styleable,
obsType,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case String str -> new Attribute<>(
property,
str,
field,
styleable,
obsType,
DisplayHint.TEXT,
ValueState.AUTO
);
// object properties
case Enum<?> enumeration -> new Attribute<>(
property,
enumeration,
field,
styleable,
obsType,
DisplayHint.ENUM,
ValueState.AUTO,
List.of(enumeration.getClass().getEnumConstants())
);
case Color color -> new Attribute<>(
property,
color,
field,
styleable,
obsType,
DisplayHint.COLOR,
ValueState.AUTO
);
case Image image -> new Attribute<>(
property,
image,
field,
styleable,
obsType,
DisplayHint.IMAGE,
ValueState.AUTO
);
case Background background -> new Attribute<>(
property,
background,
field,
styleable,
obsType,
DisplayHint.BACKGROUND,
ValueState.AUTO
);
case Border border -> new Attribute<>(
property,
border,
field,
styleable,
obsType,
DisplayHint.BORDER,
ValueState.AUTO
);
case Tooltip tooltip -> new Attribute<>(
property,
tooltip.getText(),
field,
styleable,
obsType,
DisplayHint.TEXT,
ValueState.AUTO
);
// the remainder is ObjectProperty<?>
case null, default -> new Attribute<>(
property,
String.valueOf(value),
field,
styleable,
obsType,
DisplayHint.OBJECT,
ValueState.AUTO
);
};
}
}

View File

@ -0,0 +1,168 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.geometry.Insets;
import javafx.scene.control.Control;
import javafx.scene.layout.Region;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link Region} class.
*/
@NullMarked
public final class RegionTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"padding", "insets", "snapToPixel", "shape", "scaleShape", "centerShape",
"userAgentStylesheet", "minWidth", "minHeight", "prefWidth", "prefHeight", "maxWidth", "maxHeight"
);
public RegionTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.REGION);
}
@Override
public void reload(String... properties) {
Region region = (Region) getTarget();
if (region == null) {
return;
}
reload(property -> read(region, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Region;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Region region, String property) {
return switch (property) {
case "insets" -> new Attribute<>(
"insets",
region.getInsets(),
"insetsProperty",
ObservableType.READ_ONLY,
DisplayHint.INSETS,
ValueState.defaultIf(region.getInsets() == null || Insets.EMPTY.equals(region.getInsets()))
);
case "padding" -> new Attribute<>(
"padding",
region.getPadding(),
"paddingProperty",
"-fx-padding",
ObservableType.of(region.paddingProperty()),
DisplayHint.INSETS,
ValueState.defaultIf(region.getPadding() == null || Insets.EMPTY.equals(region.getPadding()))
);
case "snapToPixel" -> new Attribute<>(
"snapToPixel",
region.isSnapToPixel(),
"snapToPixelProperty",
"-fx-snap-to-pixel",
ObservableType.of(region.snapToPixelProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(region.isSnapToPixel())
);
case "shape" -> new Attribute<>(
"shape",
region.getShape() != null ? String.valueOf(region.getShape()) : null,
"shapeProperty",
"-fx-shape",
ObservableType.of(region.shapeProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(region.getShape() == null)
);
case "scaleShape" -> new Attribute<>(
"scaleShape",
region.isScaleShape(),
"scaleShapeProperty",
"-fx-scale-shape",
ObservableType.of(region.scaleShapeProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(region.isScaleShape())
);
case "centerShape" -> new Attribute<>(
"centerShape",
region.isCenterShape(),
"centerShapeProperty",
"-fx-position-shape",
ObservableType.of(region.centerShapeProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(region.isCenterShape())
);
case "userAgentStylesheet" -> new Attribute<>(
"userAgentStylesheet",
region.getUserAgentStylesheet(),
"userAgentStylesheet",
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.defaultIf(region.getUserAgentStylesheet() == null)
);
case "minWidth" -> new Attribute<>(
"minWidth",
region.getMinWidth(),
"minWidthProperty",
"-fx-min-width",
ObservableType.of(region.minWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getMinWidth() == Control.USE_COMPUTED_SIZE)
);
case "minHeight" -> new Attribute<>(
"minHeight",
region.getMinHeight(),
"minHeightProperty",
"-fx-min-height",
ObservableType.of(region.minHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getMinHeight() == Control.USE_COMPUTED_SIZE)
);
case "prefWidth" -> new Attribute<>(
"prefWidth",
region.getPrefWidth(),
"prefWidthProperty",
"-fx-pref-width",
ObservableType.of(region.prefWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getPrefWidth() == Control.USE_COMPUTED_SIZE)
);
case "prefHeight" -> new Attribute<>(
"prefHeight",
region.getPrefHeight(),
"prefHeightProperty",
"-fx-pref-height",
ObservableType.of(region.prefHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getPrefHeight() == Control.USE_COMPUTED_SIZE)
);
case "maxWidth" -> new Attribute<>(
"maxWidth",
region.getMaxWidth(),
"maxWidthProperty",
"-fx-max-width",
ObservableType.of(region.maxWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getMaxWidth() == Control.USE_COMPUTED_SIZE)
);
case "maxHeight" -> new Attribute<>(
"maxHeight",
region.getMaxHeight(),
"maxHeightProperty",
"-fx-max-height",
ObservableType.of(region.maxHeightProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(region.getMaxHeight() == Control.USE_COMPUTED_SIZE)
);
default -> null;
};
}
}

View File

@ -0,0 +1,234 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.beans.value.ObservableValue;
import javafx.scene.Scene;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.layout.Background;
import javafx.scene.layout.Border;
import javafx.scene.paint.Color;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@NullMarked
public final class SceneTracker extends Tracker {
public static final Set<String> NON_REFLECTIVE_PROPERTIES = Set.of("stylesheets", "userData");
private final Map<String, ObservableValue<?>> orderedProperties = new TreeMap<>();
// Only one reflective tracker per target is allowed, and we already use one to obtain
// the window properties. However, the scene contains too many attributes to ignore,
// so this is the custom partially reflective implementation.
public SceneTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.SCENE);
}
@Override
public void reload(String... properties) {
Scene scene = (Scene) getTarget();
if (scene == null) {
return;
}
List<String> supportedProperties = Stream.concat(
orderedProperties.keySet().stream(),
NON_REFLECTIVE_PROPERTIES.stream()
).sorted().collect(Collectors.toList());
reload(property -> read(scene, property), supportedProperties, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Window;
}
///////////////////////////////////////////////////////////////////////////
@Override
protected void beforeSetTarget(Object target) {
scan();
}
@Override
protected void beforeResetTarget(Object target) {
orderedProperties.clear();
}
@Override
protected boolean doSetTarget(@Nullable Object candidate) {
if (candidate instanceof Window window) {
candidate = window.getScene();
}
return super.doSetTarget(candidate);
}
@SuppressWarnings({"ConstantValue"})
private void scan() {
orderedProperties.clear();
for (Map.Entry<ObservableValue<?>, String> entry : propertyListener.getProperties().entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
orderedProperties.put(entry.getValue(), entry.getKey());
}
}
}
private @Nullable Attribute<?> read(Scene scene, String property) {
if (NON_REFLECTIVE_PROPERTIES.contains(property)) {
return switch (property) {
case "stylesheets" -> {
String stylesheets = String.join("\n", scene.getStylesheets());
yield new Attribute<>(
"stylesheets",
stylesheets,
"getStylesheets",
ObservableType.LIST,
DisplayHint.TEXT,
ValueState.defaultIf(scene.getStylesheets().isEmpty())
);
}
case "userData" -> new Attribute<>(
"userData",
String.valueOf(scene.getUserData()),
"userData",
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.defaultIf(scene.getUserData() == null)
);
default -> null;
};
}
return read(property);
}
private Attribute<?> read(String property) {
ObservableValue<?> obs = orderedProperties.get(property);
Object value = obs.getValue(); // can be null and when it's null we won't get any DisplayHint...
ObservableType obsType = ObservableType.of(obs);
String field = property + PropertyListener.PROPERTY_SUFFIX;
// ValueState.NOT_APPLICABLE everywhere, because it's not possible to guess
// whether the observable property has default value or not
return switch (value) {
// primitive properties
case Boolean bool -> new Attribute<>(
property,
bool,
field,
null,
obsType,
DisplayHint.BOOLEAN,
ValueState.AUTO
);
case Integer number -> new Attribute<>(
property,
number,
field,
null,
obsType,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case Double number -> new Attribute<>(
property,
number,
field,
null,
obsType,
DisplayHint.NUMERIC,
ValueState.AUTO
);
case String str -> new Attribute<>(
property,
str,
field,
null,
obsType,
DisplayHint.TEXT,
ValueState.AUTO
);
// object properties
case Enum<?> enumeration -> new Attribute<>(
property,
enumeration,
field,
null,
obsType,
DisplayHint.ENUM,
ValueState.AUTO,
List.of(enumeration.getClass().getEnumConstants())
);
case Color color -> new Attribute<>(
property,
color,
field,
null,
obsType,
DisplayHint.COLOR,
ValueState.AUTO
);
case Image image -> new Attribute<>(
property,
image,
field,
null,
obsType,
DisplayHint.IMAGE,
ValueState.AUTO
);
case Background background -> new Attribute<>(
property,
background,
field,
null,
obsType,
DisplayHint.BACKGROUND,
ValueState.AUTO
);
case Border border -> new Attribute<>(
property,
border,
field,
null,
obsType,
DisplayHint.BORDER,
ValueState.AUTO
);
case Tooltip tooltip -> new Attribute<>(
property,
tooltip.getText(),
field,
null,
obsType,
DisplayHint.TEXT,
ValueState.AUTO
);
// the remainder is ObjectProperty<?>
case null, default -> new Attribute<>(
property,
String.valueOf(value),
field,
null,
obsType,
DisplayHint.OBJECT,
ValueState.AUTO
);
};
}
}

View File

@ -0,0 +1,157 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import static java.lang.System.Logger.Level;
/**
* The {@link Tracker} implementation for the {@link Shape} class.
*/
@NullMarked
public final class ShapeTracker extends Tracker {
private static final System.Logger LOGGER = System.getLogger(ReflectiveTracker.class.getName());
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"fill", "smooth", "stroke", "strokeType", "strokeWidth", "strokeDashArray",
"strokeDashOffset", "strokeLineCap", "strokeLineJoin", "strokeMiterLimit"
);
public ShapeTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.SHAPE);
}
@Override
public void reload(String... properties) {
Shape shape = (Shape) getTarget();
if (shape == null) {
return;
}
reload(property -> read(shape, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Shape;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Shape shape, String property) {
return switch (property) {
case "fill" -> {
if (shape.getFill() == null) {
LOGGER.log(Level.WARNING, "[Error] null shape fill for node: " + target);
}
yield new Attribute<>(
"fill",
shape.getFill(),
"fillProperty",
"-fx-fill",
ObservableType.of(shape.fillProperty()),
DisplayHint.COLOR,
ValueState.defaultIf(Color.BLACK.equals(shape.getFill()))
);
}
case "smooth" -> new Attribute<>(
"smooth",
shape.isSmooth(),
"smoothProperty",
"-fx-smooth",
ObservableType.of(shape.smoothProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(shape.isSmooth())
);
case "stroke" -> new Attribute<>(
"stroke",
shape.getStroke(),
"strokeProperty",
"-fx-stroke",
ObservableType.of(shape.strokeProperty()),
DisplayHint.COLOR,
ValueState.defaultIf((Color.BLACK.equals(shape.getFill()) &&
(shape instanceof Line || shape instanceof Polyline || shape instanceof Path)
) || shape.getStroke() == null)
);
case "strokeType" -> new Attribute<>(
"strokeType",
shape.getStrokeType(),
"strokeTypeProperty",
"-fx-stroke-type",
ObservableType.of(shape.strokeTypeProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(StrokeType.CENTERED.equals(shape.getStrokeType())),
List.of(StrokeType.values())
);
case "strokeWidth" -> new Attribute<>(
"strokeWidth",
shape.getStrokeWidth(),
"strokeWidthProperty",
"-fx-stroke-width",
ObservableType.of(shape.strokeWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(shape.getStrokeWidth() == 1)
);
case "strokeDashArray" -> new Attribute<>(
"strokeWidth",
shape.getStrokeDashArray(),
"getStrokeDashArray",
"-fx-stroke-dash-array",
ObservableType.LIST,
DisplayHint.OBJECT,
ValueState.defaultIf(shape.getStrokeDashArray().isEmpty())
);
case "strokeDashOffset" -> new Attribute<>(
"strokeDashOffset",
shape.getStrokeDashOffset(),
"strokeDashOffsetProperty",
"-fx-stroke-dash-offset",
ObservableType.of(shape.strokeDashOffsetProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(shape.getStrokeDashOffset() == 0)
);
case "strokeLineCap" -> new Attribute<>(
"strokeLineCap",
shape.getStrokeLineCap(),
"strokeLineCapProperty",
"-fx-stroke-line-cap",
ObservableType.of(shape.strokeLineCapProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(shape.getStrokeLineCap() == StrokeLineCap.SQUARE),
List.of(StrokeLineCap.values())
);
case "strokeLineJoin" -> new Attribute<>(
"strokeLineJoin",
shape.getStrokeLineJoin(),
"strokeLineJoinProperty",
"-fx-stroke-line-join",
ObservableType.of(shape.strokeLineJoinProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(shape.getStrokeLineJoin() == StrokeLineJoin.MITER),
List.of(StrokeLineJoin.values())
);
case "strokeMiterLimit" -> new Attribute<>(
"strokeMiterLimit",
shape.getStrokeMiterLimit(),
"strokeMiterLimitProperty",
"-fx-stroke-miter-limit",
ObservableType.of(shape.strokeMiterLimitProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(shape.getStrokeMiterLimit() == 10)
);
default -> null;
};
}
}

View File

@ -0,0 +1,171 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.geometry.VPos;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.scene.text.TextBoundsType;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The {@link Tracker} implementation for the {@link Text} class.
*/
@NullMarked
public final class TextTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"text", "font", "textOrigin", "x", "y", "textAlignment", "boundsType",
"tabSize", "lineSpacing", "wrappingWidth", "underline", "strikethrough", "fontSmoothingType"
);
public TextTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.TEXT);
}
@Override
public void reload(String... properties) {
Text text = (Text) getTarget();
if (text == null) {
return;
}
reload(property -> read(text, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Text;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Text text, String property) {
return switch (property) {
case "text" -> new Attribute<>(
"text",
text.getText(),
"textProperty",
ObservableType.of(text.textProperty()),
DisplayHint.TEXT,
ValueState.defaultIf(text.getText() == null)
);
case "font" -> new Attribute<>(
"font",
text.getFont(),
"fontProperty",
"-fx-font",
ObservableType.of(text.fontProperty()),
DisplayHint.FONT,
ValueState.defaultIf(text.getFont() == null)
);
case "textOrigin" -> new Attribute<>(
"textOrigin",
text.getTextOrigin(),
"textOriginProperty",
"-fx-text-origin",
ObservableType.of(text.textOriginProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(text.getTextOrigin() == null),
List.of(VPos.values())
);
case "x" -> new Attribute<>(
"x",
text.getX(),
"xProperty",
ObservableType.of(text.xProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(text.getX() == 0)
);
case "y" -> new Attribute<>(
"y",
text.getY(),
"yProperty",
ObservableType.of(text.yProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(text.getY() == 0)
);
case "textAlignment" -> new Attribute<>(
"textAlignment",
text.getTextAlignment(),
"textAlignmentProperty",
"-fx-text-alignment",
ObservableType.of(text.textAlignmentProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(text.getTextAlignment() == TextAlignment.LEFT)
);
case "boundsType" -> new Attribute<>(
"boundsType",
text.getBoundsType(),
"boundsTypeProperty",
"-fx-bounds-type",
ObservableType.of(text.boundsTypeProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(text.getBoundsType() == TextBoundsType.LOGICAL),
List.of(TextBoundsType.values())
);
case "tabSize" -> new Attribute<>(
"tabSize",
text.getTabSize(),
"tabSizeProperty",
"-fx-tab-size",
ObservableType.of(text.tabSizeProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(text.getTabSize() == 8)
);
case "lineSpacing" -> new Attribute<>(
"lineSpacing",
text.getLineSpacing(),
"lineSpacingProperty",
"-fx-line-spacing",
ObservableType.of(text.lineSpacingProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(text.getLineSpacing() == 0)
);
case "wrappingWidth" -> new Attribute<>(
"wrappingWidth",
text.getWrappingWidth(),
"wrappingWidthProperty",
ObservableType.of(text.wrappingWidthProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(text.getWrappingWidth() == 0)
);
case "underline" -> new Attribute<>(
"underline",
text.isUnderline(),
"underlineProperty",
"-fx-underline",
ObservableType.of(text.underlineProperty()),
DisplayHint.BOOLEAN,
Attribute.ValueState.defaultIf(!text.isUnderline())
);
case "strikethrough" -> new Attribute<>(
"strikethrough",
text.isStrikethrough(),
"strikethroughProperty",
"-fx-strikethrough",
ObservableType.of(text.strikethroughProperty()),
DisplayHint.BOOLEAN,
Attribute.ValueState.defaultIf(!text.isStrikethrough())
);
case "fontSmoothingType" -> new Attribute<>(
"fontSmoothingType",
text.getFontSmoothingType(),
"fontSmoothingTypeProperty",
"-fx-font-smoothing-type",
ObservableType.of(text.fontSmoothingTypeProperty()),
DisplayHint.ENUM,
ValueState.defaultIf(text.getFontSmoothingType() == FontSmoothingType.GRAY),
List.of(FontSmoothingType.values())
);
default -> null;
};
}
}

View File

@ -0,0 +1,239 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.connector.LocalElement;
import devtoolsfx.event.*;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.lang.System.Logger;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.function.Function;
/**
* The tracker is the base class for reading scene graph node properties and monitoring
* changes to those properties. Each individual property is wrapped in the corresponding
* {@link Attribute} instance, which provides additional information on how to work with
* the property. Any changes are emitted through the given {@link EventBus}.
* <p>
* It is also designed (though not yet implemented) to include additional logic for changing
* the property values.
* <p>
* There are several specific implementations for common types in the JavaFX scene graph
* hierarchy, as well as one generic implementation (the {@link ReflectiveTracker}), which
* tries to obtain all available node properties via reflection.
*/
@NullMarked
public abstract sealed class Tracker permits
ControlTracker,
GridPaneTracker,
LabeledTracker,
ImageViewTracker,
NodeTracker,
ParentTracker,
RegionTracker,
ReflectiveTracker,
SceneTracker,
ShapeTracker,
TextTracker,
WindowTracker {
private static final Logger LOGGER = System.getLogger(Tracker.class.getName());
protected final PropertyListener propertyListener = new PropertyListener() {
@Override
protected void onPropertyChanged(String propertyName, ObservableValue<?> obs) {
// emit property changes (won't work for observable collections)
reload(propertyName);
}
};
protected final EventBus eventBus;
protected final AttributeCategory category;
protected final EventSource eventSource;
protected @Nullable Object target;
protected Tracker(EventBus eventBus,
EventSource eventSource,
AttributeCategory category) {
this.eventBus = eventBus;
this.eventSource = eventSource;
this.category = category;
}
/**
* Returns the tracker category, which indicates the type of scene graph node
* with which this tracker works.
*/
public AttributeCategory getCategory() {
return category;
}
/**
* Returns the target node that is being tracked.
*/
public @Nullable Object getTarget() {
return target;
}
/**
* Sets or resets the target node to be tracked.
*/
public void setTarget(@Nullable Object target) {
if (target == null) {
reset();
return;
}
// reload (emit) all properties initially
if (doSetTarget(target)) {
reload();
}
}
/**
* Removes the tracked node, clears all listeners and emits the corresponding
* {@link AttributeListEvent}. This is the equivalent of {@code setTarget(null)}.
*/
public void reset() {
// keep direct link to avoid NPE, there's some contention
var t = target;
if (t != null) {
beforeResetTarget(t);
doSetTarget(null);
fireAttributeListEvent(List.of());
}
}
/**
* Reads all specified properties from the target node and emits the corresponding
* events. If no arguments are passed, it reads and emits all properties of the target node.
*/
public abstract void reload(String... properties);
/**
* Checks whether the given target can be accepted by the tracker implementation.
*/
public abstract boolean accepts(@Nullable Object target);
///////////////////////////////////////////////////////////////////////////
/**
* Replaces the current target with a new one.
*/
protected boolean doSetTarget(@Nullable Object candidate) {
if (target == candidate) {
return false;
}
Object old = target;
if (old != null) {
propertyListener.release();
}
if (candidate != null) {
try {
propertyListener.use(candidate);
} catch (InvocationTargetException | IllegalAccessException e) {
eventBus.fire(ExceptionEvent.of(eventSource, e));
}
// properties must be available prior to running this method
beforeSetTarget(candidate);
}
target = candidate;
return true;
}
/**
* If an implementation needs to refresh additional resources, it can include the logic here.
* This method will be called prior to setting the current target to a new value.
*/
protected void beforeSetTarget(Object target) {
// pass
}
/**
* If an implementation uses additional resources, it can include the cleanup logic here.
* This method will be called prior to setting the current target to null.
*/
protected void beforeResetTarget(Object target) {
// pass
}
/**
* A handy method to simplify reloading properties in implementations.
*/
protected void reload(Function<String, @Nullable Attribute<?>> mapper,
Collection<String> supportedProperties,
String... properties) {
if (properties.length == 0) { // hot path 1
var attributes = new ArrayList<Attribute<?>>(properties.length);
for (var property : supportedProperties) {
Attribute<?> attr = mapper.apply(property);
if (attr != null) {
attributes.add(attr);
}
}
fireAttributeListEvent(attributes);
} else if (properties.length == 1) { // hot path 2
Attribute<?> attr = mapper.apply(properties[0]);
if (attr != null) {
fireAttributeUpdatedEvent(attr);
}
} else {
Arrays.stream(properties)
.map(mapper)
.filter(Objects::nonNull)
.forEach(this::fireAttributeUpdatedEvent);
}
}
/**
* Emits an {@link AttributeListEvent} based on the target type.
*/
protected void fireAttributeListEvent(List<Attribute<?>> attributes) {
if (target instanceof Node node) {
eventBus.fire(
new AttributeListEvent(eventSource, LocalElement.of(node), category, attributes)
);
} else if (target instanceof Window window) {
eventBus.fire(
new AttributeListEvent(eventSource, LocalElement.of(window, eventSource), category, attributes)
);
} else if (target instanceof Scene scene) {
eventBus.fire(
new AttributeListEvent(eventSource, LocalElement.of(scene.getWindow(), eventSource), category, attributes)
);
} else if (target != null) {
LOGGER.log(Logger.Level.WARNING, "Unable to emit event: unknown object type '" + target.getClass() + "'");
}
}
/**
* Emits an {@link AttributeUpdatedEvent} based on the target type.
*/
protected void fireAttributeUpdatedEvent(Attribute<?> attribute) {
if (target instanceof Node node) {
eventBus.fire(
new AttributeUpdatedEvent(eventSource, LocalElement.of(node), category, attribute)
);
} else if (target instanceof Window window) {
eventBus.fire(
new AttributeUpdatedEvent(eventSource, LocalElement.of(window, eventSource), category, attribute)
);
} else if (target instanceof Scene scene) {
eventBus.fire(
new AttributeUpdatedEvent(eventSource, LocalElement.of(scene.getWindow(), eventSource), category, attribute)
);
} else if (target != null) {
LOGGER.log(Logger.Level.WARNING, "Unable to emit event: unknown object type '" + target.getClass() + "'");
}
}
}

View File

@ -0,0 +1,154 @@
package devtoolsfx.scenegraph.attributes;
import devtoolsfx.event.EventBus;
import devtoolsfx.event.EventSource;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ObservableType;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
@NullMarked
public final class WindowTracker extends Tracker {
public static final List<String> SUPPORTED_PROPERTIES = List.of(
"width", "height", "x", "y", "opacity", "focused", "showing",
"outputScaleX", "outputScaleY", "renderScaleX", "renderScaleY", "forceIntegerRenderScale", "userData"
);
public WindowTracker(EventBus eventBus, EventSource eventSource) {
super(eventBus, eventSource, AttributeCategory.WINDOW);
}
@Override
public void reload(String... properties) {
Window window = (Window) getTarget();
if (window == null) {
return;
}
reload(property -> read(window, property), SUPPORTED_PROPERTIES, properties);
}
@Override
public boolean accepts(@Nullable Object target) {
return target instanceof Window;
}
///////////////////////////////////////////////////////////////////////////
private @Nullable Attribute<?> read(Window window, String property) {
return switch (property) {
case "width" -> new Attribute<>(
"width",
window.getWidth(),
"widthProperty",
ObservableType.of(window.widthProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "height" -> new Attribute<>(
"height",
window.getHeight(),
"heightProperty",
ObservableType.of(window.heightProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "x" -> new Attribute<>(
"x",
window.getX(),
"xProperty",
ObservableType.of(window.xProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "y" -> new Attribute<>(
"y",
window.getY(),
"yProperty",
ObservableType.of(window.yProperty()),
DisplayHint.NUMERIC,
ValueState.AUTO
);
case "opacity" -> new Attribute<>(
"opacity",
window.getOpacity(),
"opacityProperty",
null,
ObservableType.of(window.opacityProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(window.getOpacity() == 1.0),
List.of(0.0, 1.0)
);
case "focused" -> new Attribute<>(
"focused",
window.isFocused(),
"focusedProperty",
ObservableType.of(window.focusedProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!window.isFocused())
);
case "showing" -> new Attribute<>(
"showing",
window.isShowing(),
"showingProperty",
ObservableType.of(window.showingProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!window.isShowing())
);
case "outputScaleX" -> new Attribute<>(
"outputScaleX",
window.getOutputScaleX(),
"outputScaleXProperty",
ObservableType.of(window.outputScaleXProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(window.getOutputScaleX() == 1.0)
);
case "outputScaleY" -> new Attribute<>(
"outputScaleY",
window.getOutputScaleY(),
"outputScaleYProperty",
ObservableType.of(window.outputScaleYProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(window.getOutputScaleY() == 1.0)
);
case "renderScaleX" -> new Attribute<>(
"renderScaleX",
window.getRenderScaleX(),
"renderScaleXProperty",
ObservableType.of(window.renderScaleXProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(window.getRenderScaleX() == 1.0)
);
case "renderScaleY" -> new Attribute<>(
"renderScaleY",
window.getRenderScaleY(),
"renderScaleYProperty",
ObservableType.of(window.renderScaleYProperty()),
DisplayHint.NUMERIC,
ValueState.defaultIf(window.getRenderScaleY() == 1.0)
);
case "forceIntegerRenderScale" -> new Attribute<>(
"forceIntegerRenderScale",
window.isForceIntegerRenderScale(),
"forceIntegerRenderScaleProperty",
ObservableType.of(window.forceIntegerRenderScaleProperty()),
DisplayHint.BOOLEAN,
ValueState.defaultIf(!window.isForceIntegerRenderScale())
);
case "userData" -> new Attribute<>(
"userData",
String.valueOf(window.getUserData()),
"userData",
ObservableType.NOT_OBSERVABLE,
DisplayHint.TEXT,
ValueState.defaultIf(window.getUserData() == null)
);
default -> null;
};
}
}

View File

@ -0,0 +1,7 @@
/**
* The package contains the API for all the supported library features and/or extensions:
* - cssfx - CSS monitoring and hot-reload
* - properties - obtaining node properties information
*/
package devtoolsfx.scenegraph.attributes;

View File

@ -0,0 +1,38 @@
package devtoolsfx.util;
import devtoolsfx.scenegraph.ClassInfo;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class ClassInfoCache {
private static final Map<Class<?>, ClassInfo> cache = new ConcurrentHashMap<>();
/**
* Returns the simple class name for the given object/
*/
public static ClassInfo get(Object obj) {
ClassInfo info = cache.get(obj.getClass());
if (info == null) {
Class<?> cls = obj.getClass();
String className = cls.getName();
String simpleName = cls.getSimpleName();
// getSimpleName() for anonymous classes in Scala does not return empty string,
// instead it will contain some encoded name
while (simpleName.isEmpty() || simpleName.contains("anon$")) {
cls = cls.getSuperclass();
className = cls.getName();
simpleName = cls.getSimpleName();
}
info = new ClassInfo(cls.getModule().getName(), className, simpleName);
cache.put(obj.getClass(), info);
return info;
}
return info;
}
}

View File

@ -0,0 +1,377 @@
package devtoolsfx.util;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.connector.LocalElement;
import devtoolsfx.scenegraph.Element;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.geometry.Point2D;
import javafx.scene.*;
import javafx.scene.control.Control;
import javafx.scene.control.PopupControl;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.stage.Window;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.lang.System.Logger.Level;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
/**
* A set of utility methods to work with scene's nodes.
*/
@NullMarked
public final class SceneUtils {
private static final System.Logger LOGGER = System.getLogger(SceneUtils.class.getName());
/**
* Returns the given node unique ID. Basically, it's just a hash code.
*/
public static int getUID(Node node) {
return node.hashCode();
}
/**
* Checks whether the given node is a normal application node or an auxiliary node.
*/
public static boolean isAuxiliaryNode(@Nullable Node node) {
return node != null
&& node.getId() != null
&& node.getId().startsWith(ConnectorOptions.AUX_NODE_ID_PREFIX);
}
/**
* See {@link #isAuxiliaryNode(Node)}.
*/
public static boolean isAuxiliaryNode(@Nullable PopupControl popup) {
return popup != null
&& popup.getId() != null
&& popup.getId().startsWith(ConnectorOptions.AUX_NODE_ID_PREFIX);
}
/**
* Returns the unmodifiable list of children of the given node or an empty list.
* This method handles the use case when the provided node is a {@link SubScene}.
*/
public static ObservableList<Node> getChildren(@Nullable Node node) {
return switch (node) {
case Parent parent -> parent.getChildrenUnmodifiable();
case SubScene subScene -> subScene.getRoot().getChildrenUnmodifiable();
case null, default -> FXCollections.emptyObservableList();
};
}
/**
* Returns the count of children for the specified node.
*/
public static int countChildren(Node node) {
return (int) getChildren(node).stream().filter(c -> !isAuxiliaryNode(c)).count();
}
/**
* Returns the count of nodes in the branch, starting from and including the given node.
*/
public static int countNodesInBranch(Node branch) {
if (isAuxiliaryNode(branch)) {
return 0;
}
int count = 1;
for (var child : getChildren(branch)) {
count += countNodesInBranch(child);
}
return count;
}
/**
* Searches for a scene graph node with the given hash code, starting from the specified node.
*/
public static @Nullable Node findNode(Node node, int hashCode) {
if (node.hashCode() == hashCode) {
return node;
}
for (var child : SceneUtils.getChildren(node)) {
return findNode(child, hashCode);
}
return null;
}
/**
* Returns the nearest instance of the {@link Pane} class starting from the
* given parent no and down to its descendants.
*/
public static @Nullable Parent findNearestPane(@Nullable Parent parent) {
if (parent == null) {
return null;
}
Parent fertile = (parent instanceof Group || parent instanceof Pane) ? parent : null;
if (fertile == null) {
for (Node child : parent.getChildrenUnmodifiable()) {
if (child instanceof Parent) {
fertile = findNearestPane((Parent) child);
}
}
}
return fertile;
}
/**
* Finds the hovered node in the target's children using the given coordinates.
*/
public static @Nullable Node findHoveredNode(@Nullable Node target,
double x,
double y,
boolean ignoreMouseTransparent) {
if (target == null || SceneUtils.isAuxiliaryNode(target)) {
return null;
}
List<Node> children = getChildren(target);
for (int i = children.size() - 1; i >= 0; i--) {
Node maybeHovered = findHoveredNode(children.get(i), x, y, ignoreMouseTransparent);
if (maybeHovered != null) {
return maybeHovered;
}
}
Point2D localPoint = target.sceneToLocal(x, y);
boolean isNotMouseTransparent = ignoreMouseTransparent || !target.isMouseTransparent();
if (target.contains(localPoint) && isNotMouseTransparent && isBranchVisible(target)) {
return target;
}
return null;
}
/**
* Adds the given node to the given parent. If the parent is not of a container type,
* then adds it to the nearest pane in the parent's descendants.
*/
public static void addToNode(Parent parent, Node node) {
if (parent instanceof Group group) {
group.getChildren().add(node);
} else if (parent instanceof Pane pane) {
pane.getChildren().add(node);
} else {
var pane = findNearestPane(parent);
if (pane != null) {
addToNode(pane, node);
}
}
}
/**
* The opposite of the {@link #addToNode(Parent, Node)}.
*/
public static void removeFromNode(Parent parent, Node node) {
if (parent instanceof Group group) {
group.getChildren().remove(node);
} else if (parent instanceof Pane pane) {
pane.getChildren().remove(node);
} else {
var pane = findNearestPane(parent);
if (pane != null) {
removeFromNode(pane, node);
}
}
}
/**
* Returns the list of stylesheets for the specified node.
*/
public static List<String> getStylesheets(Node node) {
return switch (node) {
case Control control -> Collections.unmodifiableList(control.getStylesheets());
case Parent parent -> Collections.unmodifiableList(parent.getStylesheets());
default -> List.of();
};
}
/**
* Returns the user agent stylesheet for the specified node.
*/
public static @Nullable String getUserAgentStylesheet(Node node) {
String uas = switch (node) {
case Control control -> control.getUserAgentStylesheet();
case Region region -> region.getUserAgentStylesheet();
case SubScene subScene -> subScene.getUserAgentStylesheet();
default -> null;
};
return uas != null && uas.isEmpty() ? null : uas;
}
/**
* Checks that both the given node and all it ancestors are visible.
*/
public static boolean isBranchVisible(@Nullable Node node) {
// node is visible if it's null, because it means that all descendant are visible
// up to the common ancestor, which doesn't have a parent, so the recursion ended with null
return node == null || (node.isVisible() && isBranchVisible(node.getParent()));
}
/**
* Returns the window to which the given node belongs, if any.
*/
public static @Nullable Window getWindow(Node node) {
if (node.getScene() != null && node.getScene().getWindow() != null) {
return node.getScene().getWindow();
}
return null;
}
/**
* Recursively collects the list of nodes for which {@link Parent#getStylesheets()}
* or {@link Control#getStylesheets()} is not empty.
*/
public static void collectNodesWithStyleSheets(Node node, List<Element> accumulator) {
var stylesheets = getStylesheets(node);
if (!stylesheets.isEmpty()) {
accumulator.add(LocalElement.of(node));
}
getChildren(node).forEach(child -> collectNodesWithStyleSheets(child, accumulator));
}
/**
* Determines if the specified node or any of its descendants contains
* the given stylesheet URI.
*/
public static boolean containsStylesheet(Node node, String uri) {
if (Objects.equals(getUserAgentStylesheet(node), uri)) {
return true;
}
if (getStylesheets(node).contains(uri)) {
return true;
}
for (Node child : getChildren(node)) {
return containsStylesheet(child, uri);
}
return false;
}
/**
* Null safe wrapper for calling the {@code addListener()} on a generic node.
*/
public static <N> void addListener(@Nullable N node,
Function<N, ObservableValue<?>> obs,
InvalidationListener listener) {
if (node != null) {
obs.apply(node).addListener(listener);
} else {
LOGGER.log(Level.INFO, "node is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code removeListener()} on a generic node.
*/
public static <N> void removeListener(@Nullable N node,
Function<N, ObservableValue<?>> obs,
InvalidationListener listener) {
if (node != null) {
obs.apply(node).removeListener(listener);
} else {
LOGGER.log(Level.INFO, "node is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code addListener()} on a generic node.
*/
public static <N, V> void addListener(@Nullable N node,
Function<N, ObservableValue<V>> obs,
ChangeListener<V> listener) {
if (node != null) {
obs.apply(node).addListener(listener);
} else {
LOGGER.log(Level.INFO, "node is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code removeListener()} on a generic node.
*/
public static <N, V> void removeListener(@Nullable N node,
Function<N, ObservableValue<V>> obs,
ChangeListener<V> listener) {
if (node != null) {
obs.apply(node).addListener(listener);
} else {
LOGGER.log(Level.INFO, "node is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code addEventFilter()} on a generic scene.
*/
public static <E extends Event> void addEventFilter(@Nullable Scene scene,
EventType<E> eventType,
EventHandler<? super E> eventFilter) {
if (scene != null) {
scene.addEventFilter(eventType, eventFilter);
} else {
LOGGER.log(Level.INFO, "scene is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code removeEventFilter()} on a generic scene.
*/
public static <E extends Event> void removeEventFilter(@Nullable Scene scene,
EventType<E> eventType,
EventHandler<? super E> eventFilter) {
if (scene != null) {
scene.removeEventFilter(eventType, eventFilter);
} else {
LOGGER.log(Level.INFO, "scene is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code addEventFilter()} on a generic parent.
*/
public static <E extends Event> void addEventFilter(@Nullable Parent parent,
EventType<E> eventType,
EventHandler<? super E> eventFilter) {
if (parent != null) {
parent.addEventFilter(eventType, eventFilter);
} else {
LOGGER.log(Level.INFO, "parent is null, this behavior is probably not expected");
}
}
/**
* Null safe wrapper for calling the {@code removeEventFilter()} on a generic parent.
*/
public static <E extends Event> void removeEventFilter(@Nullable Parent parent,
EventType<E> eventType,
EventHandler<? super E> eventFilter) {
if (parent != null) {
parent.removeEventFilter(eventType, eventFilter);
} else {
LOGGER.log(Level.INFO, "parent is null, this behavior is probably not expected");
}
}
}

View File

@ -0,0 +1,11 @@
module devtoolsfx.connector {
requires javafx.controls;
requires static org.jspecify;
exports devtoolsfx.connector;
exports devtoolsfx.event;
exports devtoolsfx.scenegraph;
exports devtoolsfx.scenegraph.attributes;
exports devtoolsfx.util;
}

46
demo/pom.xml Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>devtoolsfx-demo</artifactId>
<dependencies>
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx-gui</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fr.brouillard.oss</groupId>
<artifactId>cssfx</artifactId>
<version>11.4.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<executions>
<execution>
<id>default-cli</id>
<configuration>
<executable>${java.home}/bin/java</executable>
<mainClass>devtoolsfx.Launcher</mainClass>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,83 @@
package devtoolsfx;
import devtoolsfx.gui.GUI;
import fr.brouillard.oss.cssfx.CSSFX;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.Base64;
import java.util.Objects;
import java.util.Random;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javafx.scene.control.Alert.AlertType;
public class Launcher extends Application {
static final String DATA_URI_PREFIX = "data:base64,";
public static void main(String[] args) {
launch();
}
@Override
public void start(Stage primaryStage) {
var root = new VBox();
var table = new TableView<>();
table.getStylesheets().addAll(getResource("/demo.css"));
var textArea = new TextArea();
textArea.setVisible(false);
textArea.getStylesheets().add(toDataURI(
"""
.foo {
-fx-opacity: 1;
}"""
));
var infoDialogBtn = new Button("Info Dialog");
infoDialogBtn.setOnAction(e -> {
Alert alert = new Alert(AlertType.CONFIRMATION, "Test", ButtonType.YES, ButtonType.NO);
alert.initModality(Modality.NONE);
alert.showAndWait();
});
var testBtn = new Button("Test");
testBtn.setOnAction(e -> {
textArea.setVisible(!textArea.isVisible());
table.getStyleClass().add("foo-" + new Random().nextLong(100));
});
root.getChildren().setAll(
table,
textArea,
new HBox(10, infoDialogBtn, testBtn)
);
var scene = new Scene(root, 800, 600);
scene.getStylesheets().add(getResource("/demo.css"));
primaryStage.setScene(scene);
primaryStage.setTitle("Demo");
primaryStage.setOnShown(e -> GUI.openToolStage(primaryStage, getHostServices()));
primaryStage.show();
CSSFX.start();
}
static String getResource(String path) {
return Objects.requireNonNull(Launcher.class.getResource(path)).toString();
}
static String toDataURI(String css) {
if (css == null) {
throw new NullPointerException("CSS string cannot be null!");
}
return DATA_URI_PREFIX + new String(Base64.getEncoder().encode(css.getBytes(UTF_8)), UTF_8);
}
}

View File

@ -0,0 +1,8 @@
module devtoolsfx.demo {
requires javafx.controls;
requires devtoolsfx.gui;
requires fr.brouillard.oss.cssfx;
exports devtoolsfx;
}

View File

@ -0,0 +1,3 @@
.foo {
-fx-padding: 10;
}

22
gui/pom.xml Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>devtoolsfx-gui</artifactId>
<dependencies>
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>devtoolsfx-connector</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,108 @@
package devtoolsfx.gui;
import devtoolsfx.connector.LocalConnector;
import javafx.application.HostServices;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.net.URL;
import java.util.Objects;
/**
* The entry point for launching the dev tools GUI application.
*/
@NullMarked
public final class GUI {
public static final String USER_AGENT_STYLESHEET = getResource("/index.css").toString();
public static final double DEFAULT_STAGE_WIDTH = 1024;
public static final double DEFAULT_STAGE_HEIGHT = 768;
/**
* See @{@link #openToolStage(Stage, Preferences, String)}.
*/
public static void openToolStage(Stage primaryStage, HostServices hostServices) {
openToolStage(primaryStage, hostServices, null);
}
/**
* See @{@link #openToolStage(Stage, Preferences, String)}.
*/
public static void openToolStage(Stage primaryStage,
HostServices hostServices,
@Nullable String applicationName) {
openToolStage(primaryStage, new Preferences(hostServices), applicationName);
}
/**
* Starts the GUI in a separate window.
*
* @param primaryStage the primary stage of the monitored application
* @param preferences the initial GUI preferences
* @param applicationName the name of the monitored application
*/
public static void openToolStage(Stage primaryStage,
Preferences preferences,
@Nullable String applicationName) {
Objects.requireNonNull(primaryStage, "primaryStage can not be null");
Objects.requireNonNull(preferences, "hostServices can not be null");
var toolPane = createToolPane(primaryStage, preferences, applicationName);
var scene = new Scene(toolPane, DEFAULT_STAGE_WIDTH, DEFAULT_STAGE_HEIGHT);
scene.setUserAgentStylesheet(USER_AGENT_STYLESHEET);
var toolStage = new Stage();
toolStage.setScene(scene);
toolStage.setTitle("devtoolsfx");
toolStage.setOnShown(e -> toolPane.getConnector().start());
primaryStage.addEventHandler(WindowEvent.WINDOW_CLOSE_REQUEST, event -> toolStage.close());
toolStage.show();
}
/**
* See @{@link #createToolPane(Stage, Preferences, String)}.
*/
public static ToolPane createToolPane(Stage primaryStage, HostServices hostServices) {
return createToolPane(primaryStage, hostServices, null);
}
/**
* See @{@link #createToolPane(Stage, Preferences, String)}.
*/
public static ToolPane createToolPane(Stage primaryStage,
HostServices hostServices,
@Nullable String applicationName) {
return createToolPane(primaryStage, new Preferences(hostServices), applicationName);
}
/**
* Creates the GUI tool pane to be displayed as embedded within the application window.
* <p>
* The ToolPane requires its own independent user agent stylesheet. Use a Scene or SubScene to
* display it correctly.
*
* @param primaryStage the primary stage of the monitored application
* @param preferences the initial GUI preferences
* @param applicationName the name of the monitored application
*/
public static ToolPane createToolPane(Stage primaryStage,
Preferences preferences,
@Nullable String applicationName) {
Objects.requireNonNull(primaryStage, "primaryStage can not be null");
Objects.requireNonNull(preferences, "preferences can not be null");
var connector = new LocalConnector(primaryStage, applicationName);
return new ToolPane(connector, preferences);
}
///////////////////////////////////////////////////////////////////////////
private static URL getResource(String path) {
return Objects.requireNonNull(GUI.class.getResource(path));
}
}

View File

@ -0,0 +1,235 @@
package devtoolsfx.gui;
import devtoolsfx.connector.Connector;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.connector.HighlightOptions;
import devtoolsfx.scenegraph.Element;
import javafx.application.HostServices;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.Control;
import javafx.scene.layout.Pane;
import javafx.stage.PopupWindow;
import org.jspecify.annotations.NullMarked;
import java.util.Objects;
@NullMarked
public class Preferences {
public static final String JAVADOC_SEARCH_URI = "https://openjfx.io/javadoc/21/search.html";
public static final String CSS_REFERENCE_BASE_URI = "https://openjfx.io/javadoc/22/javafx.graphics/javafx/scene/doc-files/cssref.html";
public static final int MIN_EVENT_LOG_SIZE = 10;
public static final int MAX_EVENT_LOG_SIZE = 10_000_000;
public static final int DEFAULT_EVENT_LOG_SIZE = 10_000;
public static final boolean KEEP_ATTRIBUTES_SORT = true;
protected final BooleanProperty autoRefreshSceneGraph = new SimpleBooleanProperty(true);
protected final BooleanProperty preventPopupAutoHide = new SimpleBooleanProperty(true);
protected final BooleanProperty collapseControls = new SimpleBooleanProperty(true);
protected final BooleanProperty collapsePanes = new SimpleBooleanProperty(false);
protected final BooleanProperty showLayoutBounds = new SimpleBooleanProperty(true);
protected final BooleanProperty showBoundsInParent = new SimpleBooleanProperty(true);
protected final BooleanProperty showBaseline = new SimpleBooleanProperty(true);
protected final BooleanProperty ignoreMouseTransparent = new SimpleBooleanProperty(false);
protected final BooleanProperty enableEventLog = new SimpleBooleanProperty(false); // non-UI
protected final IntegerProperty maxEventLogSize = new SimpleIntegerProperty(DEFAULT_EVENT_LOG_SIZE);
protected final HostServices hostServices;
public Preferences(HostServices hostServices) {
this.hostServices = Objects.requireNonNull(hostServices, "hostServices must not be null");
}
/**
* Returns the monitored application {@link HostServices}.
* This is necessary to display a URI in a web browser while avoiding
* a dependency on the {@code java.desktop} module.
*/
public HostServices getHostServices() {
return hostServices;
}
/**
* Enables scene graph auto-refreshing.
*/
public BooleanProperty autoRefreshSceneGraphProperty() {
return autoRefreshSceneGraph;
}
public boolean isAutoRefreshSceneGraph() {
return autoRefreshSceneGraph.get();
}
public void setAutoRefreshSceneGraph(boolean autoRefreshSceneGraph) {
this.autoRefreshSceneGraph.set(autoRefreshSceneGraph);
}
/**
* Disables the {@link PopupWindow#autoHideProperty()} when the popup window appears.
* This allows inspection of the popup window content without accidentally hiding the window.
*/
public BooleanProperty preventPopupAutoHideProperty() {
return preventPopupAutoHide;
}
public boolean isPreventPopupAutoHide() {
return preventPopupAutoHide.get();
}
public void setPreventPopupAutoHide(boolean preventPopupAutoHide) {
this.preventPopupAutoHide.set(preventPopupAutoHide);
}
/**
* Enables collapsing of JavaFX {@link Control} type nodes by default.
*/
public BooleanProperty collapseControlsProperty() {
return collapseControls;
}
public boolean isCollapseControls() {
return collapseControls.get();
}
public void setCollapseControls(boolean collapseControls) {
this.collapseControls.set(collapseControls);
}
/**
* Enables collapsing of JavaFX {@link Pane} type nodes by default.
*/
public BooleanProperty collapsePanesProperty() {
return collapsePanes;
}
public boolean getCollapsePanes() {
return collapsePanes.get();
}
public void setCollapsePanes(boolean collapsePanes) {
this.collapsePanes.set(collapsePanes);
}
/**
* Enables highlighting the layoutBounds when selecting the element.
* See {@link Connector#selectNode(int, Element, HighlightOptions)}.
*/
public BooleanProperty showLayoutBoundsProperty() {
return showLayoutBounds;
}
public boolean isShowLayoutBounds() {
return showLayoutBounds.get();
}
public void setShowLayoutBounds(boolean showLayoutBounds) {
this.showLayoutBounds.set(showLayoutBounds);
}
/**
* Enables highlighting the boundInParent when selecting the element.
* See {@link Connector#selectNode(int, Element, HighlightOptions)}.
*/
public BooleanProperty showBoundsInParentProperty() {
return showBoundsInParent;
}
public boolean isShowBoundsInParent() {
return showBoundsInParent.get();
}
public void setShowBoundsInParent(boolean showBoundsInParent) {
this.showBoundsInParent.set(showBoundsInParent);
}
/**
* Enables highlighting the baselineOffset when selecting the element.
* See {@link Connector#selectNode(int, Element, HighlightOptions)}.
*/
public BooleanProperty showBaselineProperty() {
return showBaseline;
}
public boolean isShowBaseline() {
return showBaseline.get();
}
public void setShowBaseline(boolean showBaseline) {
this.showBaseline.set(showBaseline);
}
/**
* See {@link ConnectorOptions#isIgnoreMouseTransparent()}.
*/
public boolean isIgnoreMouseTransparent() {
return ignoreMouseTransparent.get();
}
public BooleanProperty ignoreMouseTransparentProperty() {
return ignoreMouseTransparent;
}
public void setIgnoreMouseTransparent(boolean ignoreMouseTransparent) {
this.ignoreMouseTransparent.set(ignoreMouseTransparent);
}
/**
* Enables or disables runtime event logging.
*/
public BooleanProperty enableEventLogProperty() {
return enableEventLog;
}
public boolean isEnableEventLog() {
return enableEventLog.get();
}
public void setEnableEventLog(boolean enableEventLog) {
this.enableEventLog.set(enableEventLog);
}
/**
* Sets the maximum size of the event log.
*/
public IntegerProperty maxEventLogSizeProperty() {
return maxEventLogSize;
}
public int getMaxEventLogSize() {
return maxEventLogSize.get();
}
public void setMaxEventLogSize(int size) {
maxEventLogSize.set(size);
}
@Override
public String toString() {
return "Preferences{" +
"autoRefreshSceneGraph=" + autoRefreshSceneGraph +
", preventPopupAutoHide=" + preventPopupAutoHide +
", collapseControls=" + collapseControls +
", collapsePanes=" + collapsePanes +
", showLayoutBounds=" + showLayoutBounds +
", showBoundsInParent=" + showBoundsInParent +
", showBaseline=" + showBaseline +
", ignoreMouseTransparent=" + ignoreMouseTransparent +
", enableEventLog=" + enableEventLog +
", maxEventLogSize=" + maxEventLogSize +
", hostServices=" + hostServices +
'}';
}
///////////////////////////////////////////////////////////////////////////
public HighlightOptions getHighlightOptions() {
return new HighlightOptions(
showLayoutBounds.get(),
showBoundsInParent.get(),
showBaseline.get()
);
}
}

View File

@ -0,0 +1,380 @@
package devtoolsfx.gui;
import devtoolsfx.connector.Connector;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.connector.Env;
import devtoolsfx.connector.HighlightOptions;
import devtoolsfx.event.*;
import devtoolsfx.gui.controls.TabLine;
import devtoolsfx.gui.env.EnvironmentTab;
import devtoolsfx.gui.eventlog.EventLogTab;
import devtoolsfx.gui.inspector.InspectorTab;
import devtoolsfx.gui.preferences.PreferencesTab;
import devtoolsfx.gui.style.StylesheetTab;
import devtoolsfx.gui.util.Formatters;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.WindowProperties;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.*;
/**
* The embeddable development tools root node.
* It is also responsible for interacting with the {@link Connector}.
*/
@NullMarked
public final class ToolPane extends BorderPane {
private static final Logger LOGGER = System.getLogger(ToolPane.class.getName());
private static final PseudoClass ACTIVE = PseudoClass.getPseudoClass("active");
// we can't use close() because we are not in FXThread
private final Connector connector;
private final ConnectorAdapter connectorAdapter = new ConnectorAdapter();
private final ConnectorOptions connectorOpts;
private final Preferences preferences;
private final Queue<ConnectorEvent> eventQueue = new ArrayDeque<>();
private final ChangeListener<Boolean> ignoreMouseTransparentListener;
private final ChangeListener<Boolean> preventPopupAutoHideListener;
private final Runnable refreshSelectionHandler;
// tabs
private final TabLine tabLine = new TabLine(
InspectorTab.TAB_NAME,
EventLogTab.TAB_NAME,
StylesheetTab.TAB_NAME,
EnvironmentTab.TAB_NAME,
PreferencesTab.TAB_NAME
);
private final StackPane tabs = new StackPane();
private final InspectorTab inspectorTab;
private final EventLogTab eventLogTab;
private final StylesheetTab stylesheetTab;
private final EnvironmentTab environmentTab;
private final PreferencesTab preferencesTab;
private final Button inspectButton = new Button();
private long lastMousePos;
public ToolPane(Connector connector, Preferences preferences) {
this.connector = Objects.requireNonNull(connector, "connector must not be null");
this.preferences = Objects.requireNonNull(preferences, "preferences must not be null");
this.connectorOpts = connector.getOptions();
// init after preferences, but before layout
inspectorTab = new InspectorTab(this);
eventLogTab = new EventLogTab(this);
stylesheetTab = new StylesheetTab(this);
environmentTab = new EnvironmentTab(this);
preferencesTab = new PreferencesTab(this);
ignoreMouseTransparentListener = (obs, old, val) -> connectorOpts.setIgnoreMouseTransparent(val);
preventPopupAutoHideListener = (obs, old, val) -> connectorOpts.setPreventPopupAutoHide(val);
refreshSelectionHandler = () -> getConnector().refreshSelection();
createLayout();
initListeners();
getStylesheets().add(GUI.USER_AGENT_STYLESHEET);
tabLine.selectTab(InspectorTab.TAB_NAME);
startListenToEvents(false);
}
/**
* Returns the GUI preferences.
*/
public Preferences getPreferences() {
return preferences;
}
/**
* Returns the selected scene graph tree element.
*/
public @Nullable Element getSelectedElement() {
return inspectorTab.getSelectedTreeElement();
}
/**
* Returns the wrapper that groups connector methods to avoid name conflicts
* and encapsulates the logic in a single location.
*/
public ConnectorAdapter getConnector() {
return connectorAdapter;
}
public class ConnectorAdapter {
/**
* See {@link Connector#start()}.
*/
public void start() {
connector.start();
}
/**
* See {@link Connector#stop()}}.
*/
public void stop() {
connector.start();
}
/**
* See {@link Connector#getEnv()}}.
*/
public Env getEnv() {
return connector.getEnv();
}
/**
* See {@link Connector#selectNode(int, Element, HighlightOptions)}}.
*/
public void selectElement(int uid, Element element) {
inspectorTab.clearAttributes();
if (element.isWindowElement()) {
connector.selectWindow(uid);
}
if (element.isNodeElement()) {
connector.selectNode(uid, element, preferences.getHighlightOptions());
}
}
/**
* See {@link Connector#reloadSelectedAttributes(int, AttributeCategory, String)}}.
*/
public void reloadSelectedAttributes(@Nullable AttributeCategory category, @Nullable String property) {
Element selected = inspectorTab.getSelectedTreeElement();
if (selected == null) {
return;
}
int uid = inspectorTab.getWindow(selected);
if (uid == 0) {
return;
}
connector.reloadSelectedAttributes(uid, category, property);
}
/**
* See {@link Connector#clearSelection(int)}}.
*/
public void clearSelection(int uid) {
connector.clearSelection(uid);
}
public void refreshSelection() {
Element selected = inspectorTab.getSelectedTreeElement();
if (selected == null) {
return;
}
int uid = inspectorTab.getWindow(selected);
if (uid == 0) {
return;
}
selectElement(uid, selected);
}
/**
* See {@link Connector#hideWindow(int)}}.
*/
public void hideWindow(int uid) {
connector.hideWindow(uid);
}
/**
* Returns the identifiers of the currently monitored windows.
*/
public List<Integer> getMonitorIdentifiers() {
return connector.getEventSources().stream().map(EventSource::uid).toList();
}
/**
* See {@link Connector#getStyledElements(int)}}.
*/
public Map.@Nullable Entry<WindowProperties, List<Element>> getStyledElements(int uid) {
return connector.getStyledElements(uid);
}
/**
* See {@link Connector#getUserAgentStylesheet()}}.
*/
public String getUserAgentStylesheet() {
return connector.getUserAgentStylesheet();
}
/**
* See {@link Connector#getResource(int, String)}}.
*/
public @Nullable String getResource(int uid, String uri) {
return connector.getResource(uid, uri);
}
/**
* See {@link Connector#getDeclaringClass(String, String)}}.
*/
public @Nullable String getDeclaringClass(String className, String property) {
return connector.getDeclaringClass(className, property);
}
}
/**
* Handles the GUI exceptions.
* <p>
* Note: We can't use the {@link UncaughtExceptionHandler} because the embedded GUI operates
* in the FXThread and the target application may or want to set its own exception handler.
*/
public void handleException(Exception e) {
LOGGER.log(Level.WARNING, Formatters.exceptionToString(e));
}
///////////////////////////////////////////////////////////////////////////
// UI construction //
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
var icon = new StackPane();
icon.getStyleClass().add("icon");
inspectButton.setGraphic(icon);
inspectButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
inspectButton.setId("inspect-button");
tabLine.getChildren().addFirst(inspectButton);
tabs.getChildren().setAll(
inspectorTab,
eventLogTab,
stylesheetTab,
environmentTab,
preferencesTab
);
setTop(tabLine);
setCenter(tabs);
}
private void initListeners() {
preferences.ignoreMouseTransparentProperty().addListener(ignoreMouseTransparentListener);
connectorOpts.setIgnoreMouseTransparent(preferences.isIgnoreMouseTransparent());
preferences.preventPopupAutoHideProperty().addListener(preventPopupAutoHideListener);
connectorOpts.setPreventPopupAutoHide(preferences.isPreventPopupAutoHide());
preferences.showLayoutBoundsProperty().subscribe(refreshSelectionHandler);
preferences.showBoundsInParentProperty().subscribe(refreshSelectionHandler);
preferences.showBaselineProperty().subscribe(refreshSelectionHandler);
tabLine.setOnTabSelect(tab -> {
switch (tab) {
case InspectorTab.TAB_NAME -> inspectorTab.toFront();
case EventLogTab.TAB_NAME -> eventLogTab.toFront();
case StylesheetTab.TAB_NAME -> {
stylesheetTab.toFront();
stylesheetTab.update();
}
case EnvironmentTab.TAB_NAME -> {
environmentTab.toFront();
environmentTab.update();
}
case PreferencesTab.TAB_NAME -> preferencesTab.toFront();
}
});
inspectButton.setOnAction(e -> {
boolean enabled = !connectorOpts.isInspectMode();
connectorOpts.setInspectMode(enabled);
inspectButton.pseudoClassStateChanged(ACTIVE, enabled);
});
}
///////////////////////////////////////////////////////////////////////////
// Event Handling //
///////////////////////////////////////////////////////////////////////////
@SuppressWarnings("SameParameterValue")
private void startListenToEvents(boolean useQueue) {
if (useQueue) {
// Optionally, we can update the GUI on a separate queue, which adds a small delay.
// This is how it was implemented before and was left as an option.
Timeline eventDispatcher = new Timeline(new KeyFrame(Duration.millis(60), event -> {
// no need to synchronize
while (!eventQueue.isEmpty()) {
try {
dispatchEvent(eventQueue.poll());
} catch (Exception e) {
handleException(e);
}
}
}));
eventDispatcher.setCycleCount(Animation.INDEFINITE);
eventDispatcher.play();
}
connector.getEventBus().subscribe(ConnectorEvent.class, event -> {
if (event instanceof MousePosEvent) {
// traffic protection
if (System.currentTimeMillis() - lastMousePos < 500) {
return;
}
lastMousePos = System.currentTimeMillis();
}
if (useQueue) {
eventQueue.offer(event);
} else {
dispatchEvent(event);
}
});
}
private void dispatchEvent(ConnectorEvent connectorEvent) {
eventLogTab.offer(connectorEvent);
switch (connectorEvent) {
case AttributeListEvent event -> inspectorTab.setAttributes(
event.category(), event.attributes()
);
case AttributeUpdatedEvent event -> inspectorTab.updateAttribute(
event.category(), event.attribute()
);
case NodeAddedEvent event -> inspectorTab.addTreeElement(event.element());
case NodeRemovedEvent event -> inspectorTab.removeTreeElement(event.element());
case NodeSelectedEvent event -> {
connectorOpts.setInspectMode(false);
inspectButton.pseudoClassStateChanged(ACTIVE, false);
inspectorTab.selectTreeElement(event.element());
}
case NodeStyleClassEvent event -> inspectorTab.updateTreeElementStyleClass(
event.element(), event.styleClass()
);
case NodeVisibilityEvent event -> inspectorTab.updateTreeElementVisibilityState(
event.element(), event.visible()
);
case RootChangedEvent event -> inspectorTab.addOrUpdateWindow(event.element());
case WindowClosedEvent event -> inspectorTab.removeWindow(event.eventSource().uid());
default -> {
// if there's no specific event here, then it's just for logging
}
}
}
}

View File

@ -0,0 +1,16 @@
package devtoolsfx.gui.controls;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
public final class ColorIndicator extends Rectangle {
public ColorIndicator(Color color) {
super();
getStyleClass().add("color-indicator");
setFill(color);
setWidth(10);
setHeight(10);
}
}

View File

@ -0,0 +1,49 @@
package devtoolsfx.gui.controls;
import devtoolsfx.gui.GUI;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.jspecify.annotations.NullMarked;
import java.util.Objects;
/**
* A utility wrapper for modal dialogs.
*/
@NullMarked
public final class Dialog<P extends Parent> extends Stage {
private static final double DEFAULT_WIDTH = 640;
private static final double DEFAULT_HEIGHT = 480;
private final P root;
public Dialog(P root) {
this(root, "", DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
public Dialog(P root, String title, double width, double height) {
super();
this.root = Objects.requireNonNull(root, "parent node cannot be null");
createLayout(root, title, width, height);
}
public P getRoot() {
return root;
}
private void createLayout(P parent, String title, double width, double height) {
setTitle(title);
initModality(Modality.NONE);
var scene = new Scene(parent);
scene.getStylesheets().add(GUI.USER_AGENT_STYLESHEET);
setWidth(width);
setHeight(height);
setScene(scene);
}
}

View File

@ -0,0 +1,76 @@
package devtoolsfx.gui.controls;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public final class FilterField extends HBox {
private final TextField textField = new TextField();
private final Button clearButton = new Button();
public FilterField() {
super();
createLayout();
initListeners();
}
public String getText() {
return textField.getText() != null ? textField.getText().trim() : "";
}
public void setText(@Nullable String text) {
textField.setText(text != null ? text.trim() : "");
}
public void setPromptText(String text) {
textField.setPromptText(text);
}
public void setOnTextChange(@Nullable Runnable handler) {
if (handler != null) {
textField.setOnKeyReleased(event -> handler.run());
}
}
public void setOnClearButtonClick(@Nullable Runnable handler) {
if (handler != null) {
clearButton.setOnMousePressed(event -> handler.run());
}
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
textField.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(textField, Priority.ALWAYS);
textField.setContextMenu(new TextInputContextMenu(textField));
var icon = new StackPane();
icon.getStyleClass().add("icon");
clearButton.setGraphic(icon);
clearButton.getStyleClass().add("clear-button");
clearButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
HBox.setHgrow(clearButton, Priority.NEVER);
clearButton.setVisible(false);
clearButton.setManaged(false);
getStyleClass().add("filter-field");
setAlignment(Pos.CENTER_LEFT);
getChildren().addAll(textField, clearButton);
}
private void initListeners() {
clearButton.visibleProperty().bind(textField.textProperty().isEmpty().not());
clearButton.managedProperty().bind(textField.textProperty().isEmpty());
}
}

View File

@ -0,0 +1,49 @@
package devtoolsfx.gui.controls;
import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
@NullMarked
public final class FilterableTreeItem<T> extends TreeItem<T> {
private final ObservableList<TreeItem<T>> sourceList = FXCollections.observableArrayList();
private final FilteredList<TreeItem<T>> filteredList = new FilteredList<>(sourceList);
public FilterableTreeItem() {
this(null);
}
public FilterableTreeItem(@Nullable T value) {
super(value);
Bindings.bindContent(getChildren(), filteredList);
}
public void setItems(List<TreeItem<T>> items) {
sourceList.setAll(items);
}
public List<TreeItem<T>> getChildrenUnmodifiable() {
return Collections.unmodifiableList(sourceList);
}
public boolean isEmpty() {
return getChildren().isEmpty();
}
public void clear() {
sourceList.clear();
}
public void setFilterPredicate(@Nullable Predicate<? super TreeItem<T>> predicate) {
filteredList.setPredicate(predicate);
}
}

View File

@ -0,0 +1,69 @@
package devtoolsfx.gui.controls;
import javafx.beans.value.ChangeListener;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.Objects;
import java.util.function.Consumer;
@NullMarked
public final class TabLine extends HBox {
private @Nullable Consumer<String> selectionHandler;
private final ToggleGroup group = new ToggleGroup();
private final ChangeListener<@Nullable Toggle> selectionListener = (obs, old, val) -> {
if (val == null && old != null) {
old.setSelected(true);
return;
}
if (selectionHandler != null && val != null && val.getUserData() instanceof String tab) {
selectionHandler.accept(tab);
}
};
public TabLine(String... tabs) {
super();
getStyleClass().add("tab-line");
createLayout(tabs);
}
public void selectTab(String tab) {
group.getToggles().stream()
.filter(t -> Objects.equals(t.getUserData(), tab) && !t.isSelected())
.findFirst()
.ifPresent(t -> t.setSelected(true));
}
public void setOnTabSelect(@Nullable Consumer<String> selectionHandler) {
this.selectionHandler = selectionHandler;
}
///////////////////////////////////////////////////////////////////////////
private void createLayout(String... tabs) {
var buttons = new ArrayList<ToggleButton>(tabs.length);
for (var tab : tabs) {
var button = new ToggleButton(tab);
button.setMaxWidth(Double.MAX_VALUE);
button.setUserData(tab);
HBox.setHgrow(button, Priority.ALWAYS);
buttons.add(button);
}
group.getToggles().setAll(buttons);
group.selectedToggleProperty().addListener(selectionListener);
getChildren().setAll(buttons);
}
}

View File

@ -0,0 +1,72 @@
package devtoolsfx.gui.controls;
import devtoolsfx.connector.ConnectorOptions;
import javafx.scene.control.*;
import java.util.function.Consumer;
/**
* There is no API to modify the default text input context menu, so this serves as a replacement.
* However, it still lacks internationalization (i18n) support because it is not public.
*/
public class TextInputContextMenu extends ContextMenu {
public TextInputContextMenu(TextInputControl control) {
super();
setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "textInputContextMenu");
createMenu(control);
}
private void createMenu(TextInputControl control) {
var undo = menuItem("Undo", control, TextInputControl::undo);
undo.setDisable(true);
var redo = menuItem("Redo", control, TextInputControl::redo);
redo.setDisable(true);
var cut = menuItem("Cut", control, TextInputControl::cut);
cut.setDisable(true);
var copy = menuItem("Copy", control, TextInputControl::copy);
copy.setDisable(true);
var paste = menuItem("Paste", control, TextInputControl::paste);
var selectAll = menuItem("Select All", control, TextInputControl::selectAll);
selectAll.setDisable(true);
var delete = menuItem("Delete", control, this::deleteSelectedText);
delete.setDisable(true);
control.undoableProperty().addListener((obs, old, val) -> undo.setDisable(!val));
control.redoableProperty().addListener((obs, old, val) -> redo.setDisable(!val));
control.selectionProperty().addListener((obs, old, val) -> {
cut.setDisable(val.getLength() == 0);
copy.setDisable(val.getLength() == 0);
delete.setDisable(val.getLength() == 0);
selectAll.setDisable(val.getLength() == val.getEnd());
});
getItems().setAll(undo, redo, cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);
}
protected MenuItem menuItem(String text, TextInputControl control, Consumer<TextInputControl> action) {
var item = new MenuItem(text);
item.setOnAction(e -> action.accept(control));
return item;
}
protected void deleteSelectedText(TextInputControl control) {
IndexRange range = control.getSelection();
if (range.getLength() == 0) {
return;
}
String text = control.getText();
String newText = text.substring(0, range.getStart()) + text.substring(range.getEnd());
control.setText(newText);
control.positionCaret(range.getStart());
}
}

View File

@ -0,0 +1,37 @@
package devtoolsfx.gui.controls;
import devtoolsfx.connector.ConnectorOptions;
import javafx.scene.control.TextArea;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* A component for displaying text (monospace by default).
*/
@NullMarked
public class TextView extends VBox {
private final TextArea textArea = new TextArea();
public TextView() {
super();
createLayout();
}
public void setText(@Nullable String text) {
textArea.setText(text);
}
private void createLayout() {
textArea.setEditable(false);
textArea.setWrapText(true);
VBox.setVgrow(textArea, Priority.ALWAYS);
getChildren().setAll(textArea);
setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "textView");
getStyleClass().add("text-view");
}
}

View File

@ -0,0 +1,241 @@
package devtoolsfx.gui.env;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.connector.Env;
import devtoolsfx.connector.KeyValue;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.gui.controls.Dialog;
import devtoolsfx.gui.controls.FilterField;
import devtoolsfx.gui.controls.FilterableTreeItem;
import devtoolsfx.gui.controls.TextView;
import devtoolsfx.gui.util.GUIHelpers;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
@NullMarked
public class EnvironmentTab extends VBox {
public static final String TAB_NAME = "Environment";
private static final int MIN_FILTER_LENGTH = 3;
private static final PseudoClass GROUP = PseudoClass.getPseudoClass("group");
private static final PseudoClass LEAF = PseudoClass.getPseudoClass("leaf");
private final FilterField filterField = new FilterField();
private final TreeTableView<KeyValue> kvTable = new TreeTableView<>();
private final FilterableTreeItem<KeyValue> treeRoot = new FilterableTreeItem<>();
private final FilterableTreeItem<KeyValue> platformRoot = new FilterableTreeItem<>(
new KeyValue("Platform", null)
);
private final FilterableTreeItem<KeyValue> propertiesRoot = new FilterableTreeItem<>(
new KeyValue("System Properties", null)
);
private final FilterableTreeItem<KeyValue> envVariablesRoot = new FilterableTreeItem<>(
new KeyValue("Environment Variables", null)
);
private @Nullable Dialog<TextView> textViewDialog = null;
private final Env env;
public EnvironmentTab(ToolPane toolPane) {
super();
this.env = toolPane.getConnector().getEnv();
createLayout();
initListeners();
}
public void update() {
var platformProps = Stream.of(
env.getConditionalFeatures(),
env.getPlatformPreferences(),
env.getOtherPlatformProperties()
)
.flatMap(Collection::stream)
.sorted()
.map(TreeItem::new)
.toList();
platformRoot.setItems(platformProps);
var systemProps = env.getSystemProperties().stream()
.sorted()
.map(TreeItem::new)
.toList();
propertiesRoot.setItems(systemProps);
var envVariables = env.getEnvVariables().stream()
.sorted()
.map(TreeItem::new)
.toList();
envVariablesRoot.setItems(envVariables);
setFilter(filterField.getText());
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
filterField.setPromptText("filter");
HBox.setHgrow(filterField, Priority.ALWAYS);
filterField.setMinHeight(Region.USE_PREF_SIZE);
filterField.setMaxHeight(Region.USE_PREF_SIZE);
var filterBox = new HBox(filterField);
filterBox.getStyleClass().add("filter");
filterBox.setFillHeight(true);
VBox.setVgrow(filterBox, Priority.NEVER);
platformRoot.setExpanded(true);
propertiesRoot.setExpanded(true);
envVariablesRoot.setExpanded(true);
treeRoot.setItems(List.of(platformRoot, propertiesRoot, envVariablesRoot));
kvTable.setRoot(treeRoot);
kvTable.setShowRoot(false);
createTableColumns();
kvTable.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
kvTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
kvTable.setContextMenu(createContextMenu());
VBox.setVgrow(kvTable, Priority.ALWAYS);
setId("environment-tab");
getStyleClass().setAll("tab");
getChildren().setAll(filterBox, kvTable);
}
private void initListeners() {
filterField.setOnTextChange(() -> setFilter(filterField.getText()));
filterField.setOnClearButtonClick(() -> {
filterField.setText(null);
setFilter(null);
});
kvTable.setOnMouseClicked(event -> {
if (MouseButton.PRIMARY.equals(event.getButton()) && event.getClickCount() == 2 && !kvTable.getSelectionModel().isEmpty()) {
var item = kvTable.getSelectionModel().getSelectedItem();
if (item.getValue().value() == null) {
return;
}
var dialog = getOrCreateTextViewDialog();
dialog.getRoot().setText(item.getValue().key() + "=" + item.getValue().value());
dialog.show();
dialog.toFront();
}
});
kvTable.setOnKeyPressed(e -> {
if (new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_ANY).match(e)) {
GUIHelpers.copySelectedRowsToClipboard(kvTable, kv -> kv.key() + "=" + kv.value());
}
});
}
@SuppressWarnings("unchecked")
private void createTableColumns() {
var keyCol = new TreeTableColumn<KeyValue, String>("Property");
keyCol.setCellValueFactory(param -> columnMapper(param, KeyValue::key));
keyCol.setCellFactory(c -> new TreeTableCell<>() {
{
getStyleClass().add("key-cell");
}
@Override
protected void updateItem(@Nullable String key, boolean empty) {
super.updateItem(key, empty);
if (empty) {
setText(null);
return;
}
pseudoClassStateChanged(LEAF, getTableRow().getTreeItem().isLeaf());
getTableRow().pseudoClassStateChanged(GROUP, getTableRow().getTreeItem() instanceof FilterableTreeItem);
setText(key);
}
});
keyCol.setSortable(false);
keyCol.setReorderable(false);
keyCol.setPrefWidth(30);
var valueCol = new TreeTableColumn<KeyValue, String>("Value");
valueCol.setCellValueFactory(param -> columnMapper(param, KeyValue::value));
valueCol.setSortable(false);
valueCol.setReorderable(false);
kvTable.getColumns().setAll(keyCol, valueCol);
}
private ContextMenu createContextMenu() {
var refresh = new MenuItem("Refresh");
refresh.setOnAction(e -> update());
var contextMenu = new ContextMenu();
contextMenu.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "envTableOptionsMenu");
contextMenu.getItems().addAll(refresh);
return contextMenu;
}
private void setFilter(@Nullable String filter) {
if (filter != null && filter.length() >= MIN_FILTER_LENGTH) {
platformRoot.setFilterPredicate(item -> containsIgnoreCase(item.getValue().key(), filter));
propertiesRoot.setFilterPredicate(item -> containsIgnoreCase(item.getValue().key(), filter));
envVariablesRoot.setFilterPredicate(item -> containsIgnoreCase(item.getValue().key(), filter));
} else {
platformRoot.setFilterPredicate(null);
propertiesRoot.setFilterPredicate(null);
envVariablesRoot.setFilterPredicate(null);
}
refreshRootFilter();
}
private void refreshRootFilter() {
treeRoot.setFilterPredicate(null);
treeRoot.setFilterPredicate(
item -> item instanceof FilterableTreeItem<KeyValue> filterable && !filterable.isEmpty()
);
}
private <T extends TreeTableColumn.CellDataFeatures<@Nullable KeyValue, String>> ObservableValue<@Nullable String> columnMapper(
T cdf, Function<KeyValue, String> mapper) {
if (cdf.getValue() == null || cdf.getValue().getValue() == null) {
return new SimpleStringProperty(null);
}
return new SimpleStringProperty(mapper.apply(cdf.getValue().getValue()));
}
private Dialog<TextView> getOrCreateTextViewDialog() {
if (textViewDialog == null) {
textViewDialog = new Dialog<>(new TextView(), "Details", 640, 480);
}
return textViewDialog;
}
private boolean containsIgnoreCase(@Nullable String str, @Nullable String subStr) {
return str != null && (subStr == null || str.toLowerCase().contains(subStr.toLowerCase()));
}
}

View File

@ -0,0 +1,239 @@
package devtoolsfx.gui.eventlog;
import devtoolsfx.event.ConnectorEvent;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.gui.controls.Dialog;
import devtoolsfx.gui.controls.FilterField;
import devtoolsfx.gui.controls.TextView;
import devtoolsfx.gui.util.Formatters;
import devtoolsfx.gui.util.GUIHelpers;
import javafx.css.PseudoClass;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.function.Supplier;
@NullMarked
public final class EventLogTab extends VBox {
public static final String TAB_NAME = "Events";
private static final PseudoClass STARTED = PseudoClass.getPseudoClass("started");
private static final int MIN_FILTER_LENGTH = 3;
private static final int MAX_NUMBER_OF_LINES = 3;
private final ToolPane toolPane;
private final Log log;
private final ListView<Log.Entry> logView = new ListView<>();
private final Button startStopButton = new Button();
private final Button clearButton = new Button();
private final Button exportButton = new Button();
private final FilterField filterField = new FilterField();
private final OptionsMenuButton optionsMenu = new OptionsMenuButton(e -> {
updateFilter();
updateStatusLabel();
});
private final Label statusLabel = new Label();
private @Nullable Dialog<TextView> textViewDialog = null;
public EventLogTab(ToolPane toolPane) {
super();
this.toolPane = toolPane;
this.log = new Log(toolPane.getPreferences().getMaxEventLogSize());
createLayout();
initListeners();
updateFilter();
updateStatusLabel();
}
public void offer(ConnectorEvent event) {
if (toolPane.getPreferences().isEnableEventLog()) {
log.add(Log.Entry.of(event));
updateStatusLabel();
}
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
Supplier<Pane> iconGenerator = () -> {
var pane = new StackPane();
pane.getStyleClass().add("icon");
return pane;
};
startStopButton.setGraphic(iconGenerator.get());
startStopButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
startStopButton.getStyleClass().add("start-stop-button");
clearButton.setGraphic(iconGenerator.get());
clearButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
clearButton.getStyleClass().add("clear-button");
exportButton.setGraphic(iconGenerator.get());
exportButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
exportButton.getStyleClass().add("export-button");
filterField.setPromptText("filter");
HBox.setHgrow(filterField, Priority.ALWAYS);
filterField.setMinHeight(Region.USE_PREF_SIZE);
filterField.setMaxHeight(Region.USE_PREF_SIZE);
var controlsBox = new HBox();
controlsBox.getStyleClass().add("controls");
controlsBox.getChildren().setAll(startStopButton, clearButton, exportButton, filterField, optionsMenu);
VBox.setVgrow(controlsBox, Priority.NEVER);
logView.getStyleClass().add("log-view");
logView.setItems(log.getFilteredEntries());
logView.setCellFactory(c -> new ListCell<>() {
@Override
protected void updateItem(Log.Entry entry, boolean empty) {
super.updateItem(entry, empty);
setText(!empty
? Formatters.limitNumberOfLines(entry.toLogString(), MAX_NUMBER_OF_LINES, "\n...")
: null
);
}
});
VBox.setVgrow(logView, Priority.ALWAYS);
var statusBar = new HBox(statusLabel);
statusBar.getStyleClass().add("status-bar");
VBox.setVgrow(statusBar, Priority.NEVER);
updateStatusLabel();
setId("event-log-tab");
getStyleClass().setAll("tab");
getChildren().setAll(controlsBox, logView, statusBar);
}
private void initListeners() {
startStopButton.setOnAction(e -> {
boolean nextState = !toolPane.getPreferences().isEnableEventLog();
toolPane.getPreferences().setEnableEventLog(nextState);
startStopButton.pseudoClassStateChanged(STARTED, nextState);
updateFilter();
updateStatusLabel();
});
clearButton.setOnAction(e -> {
log.clear();
updateFilter();
updateStatusLabel();
});
clearButton.disableProperty().bind(log.emptyProperty());
exportButton.disableProperty().bind(log.emptyProperty());
exportButton.setOnAction(event -> {
var dialog = new FileChooser();
dialog.setTitle("Save File");
dialog.setInitialFileName("event-log.txt");
dialog.setInitialDirectory(Paths.get(System.getProperty("user.home")).toFile());
dialog.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("Text Files", "*.txt"),
new FileChooser.ExtensionFilter("All Files", "*.*")
);
var file = dialog.showSaveDialog(exportButton.getScene().getWindow());
if (file != null) {
exportLog(file);
}
});
filterField.setOnTextChange(() -> {
updateFilter();
updateStatusLabel();
});
filterField.setOnClearButtonClick(() -> filterField.setText(""));
logView.setOnMouseClicked(event -> {
if (MouseButton.PRIMARY.equals(event.getButton()) && event.getClickCount() == 2 && !logView.getSelectionModel().isEmpty()) {
var entry = logView.getSelectionModel().getSelectedItem();
var dialog = getOrCreateTextViewDialog();
dialog.getRoot().setText(String.valueOf(entry));
dialog.show();
dialog.toFront();
}
});
logView.setOnKeyPressed(e -> {
if (new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_ANY).match(e)) {
GUIHelpers.copySelectedRowsToClipboard(logView, Log.Entry::toLogString);
}
});
toolPane.getPreferences().maxEventLogSizeProperty().addListener(
(obs, old, val) -> log.setMaxSize((int) val)
);
}
@SuppressWarnings("RedundantIfStatement")
private void updateFilter() {
log.setFilterPredicate(entry -> {
if (!optionsMenu.isEventEnabled(entry.event())) {
return false;
}
if (optionsMenu.isFilterSelectedOnly() && (toolPane.getSelectedElement() == null
|| !entry.matches(toolPane.getSelectedElement()))) {
return false;
}
var text = filterField.getText();
if ((text.length() >= MIN_FILTER_LENGTH && !entry.matches(text))) {
return false;
}
return true;
});
}
private void updateStatusLabel() {
int totalSize = log.getEntries().size();
int filteredSize = log.getFilteredEntries().size();
statusLabel.setText(filteredSize == totalSize
? totalSize + " entries"
: filteredSize + "/" + totalSize + " entries"
);
}
private void exportLog(File file) {
var sb = new StringBuilder();
for (var entry : new ArrayList<>(log.getFilteredEntries())) {
sb.append(entry.toLogString());
sb.append("\n");
}
try {
Files.writeString(file.toPath(), sb.toString());
} catch (IOException e) {
toolPane.handleException(e);
}
}
private Dialog<TextView> getOrCreateTextViewDialog() {
if (textViewDialog == null) {
textViewDialog = new Dialog<>(new TextView(), "Log Entry", 640, 480);
}
return textViewDialog;
}
}

View File

@ -0,0 +1,89 @@
package devtoolsfx.gui.eventlog;
import devtoolsfx.event.ConnectorEvent;
import devtoolsfx.event.ElementEvent;
import devtoolsfx.scenegraph.Element;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedList;
import java.util.Objects;
import java.util.function.Predicate;
@NullMarked
final class Log {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private static final int UNLIMITED = -1;
private final ObservableList<Entry> sourceList = FXCollections.observableList(new LinkedList<>());
private final FilteredList<Entry> filteredList = new FilteredList<>(sourceList);
private final BooleanBinding emptyProperty = Bindings.size(sourceList).isEqualTo(0);
private int maxSize = UNLIMITED;
public Log(int maxSize) {
setMaxSize(maxSize);
}
void add(Entry entry) {
if (sourceList.size() == maxSize) {
sourceList.removeLast();
}
sourceList.addFirst(entry);
}
ObservableList<Entry> getEntries() {
return sourceList;
}
ObservableList<Entry> getFilteredEntries() {
return filteredList;
}
void setMaxSize(int maxSize) {
this.maxSize = maxSize <= 0 ? UNLIMITED : maxSize;
}
BooleanBinding emptyProperty() {
return emptyProperty;
}
void clear() {
sourceList.clear();
}
void setFilterPredicate(@Nullable Predicate<Entry> predicate) {
filteredList.setPredicate(predicate);
}
///////////////////////////////////////////////////////////////////////////
public record Entry(LocalDateTime timestamp, ConnectorEvent event) {
public boolean matches(Element element) {
return event instanceof ElementEvent elementEvent && Objects.equals(elementEvent.getElement(), element);
}
public boolean matches(String text) {
return toLogString().contains(text);
}
public String toLogString() {
String date = DATE_FORMAT.format(timestamp());
String eventClass = String.format("%-24s", event().getClass().getSimpleName() + ":");
return date + " " + eventClass + event().toLogString();
}
public static Entry of(ConnectorEvent event) {
return new Entry(LocalDateTime.now(), event);
}
}
}

View File

@ -0,0 +1,93 @@
package devtoolsfx.gui.eventlog;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.event.ConnectorEvent;
import devtoolsfx.event.JavaFXEvent;
import devtoolsfx.event.MousePosEvent;
import devtoolsfx.event.WindowPropertiesEvent;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.CheckMenuItem;
import javafx.scene.control.MenuButton;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import org.jspecify.annotations.NullMarked;
import java.util.*;
@NullMarked
final class OptionsMenuButton extends MenuButton {
private static final Set<Class<? extends ConnectorEvent>> PREDISABLED_EVENTS = Set.of(
JavaFXEvent.class,
MousePosEvent.class,
WindowPropertiesEvent.class
);
private final CheckMenuItem selectedOnlyItem = new CheckMenuItem("For selected node only");
private final Map<Class<?>, CheckMenuItem> eventItems = new HashMap<>();
OptionsMenuButton(EventHandler<ActionEvent> actionHandler) {
super("Options");
Objects.requireNonNull(actionHandler, "action handler must not be null");
setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "eventLogOptionsMenu");
createMenuItems(actionHandler);
}
boolean isFilterSelectedOnly() {
return selectedOnlyItem.isSelected();
}
<T extends ConnectorEvent> boolean isEventEnabled(T event) {
var item = eventItems.get(event.getClass());
return item != null && item.isSelected();
}
///////////////////////////////////////////////////////////////////////////
private void createMenuItems(EventHandler<ActionEvent> actionHandler) {
selectedOnlyItem.setOnAction(actionHandler);
getItems().addAll(
selectedOnlyItem,
new SeparatorMenuItem()
);
Arrays.stream(ConnectorEvent.class.getPermittedSubclasses())
.sorted(Comparator.comparing(Class::getSimpleName))
.forEach(cls -> getItems().add(createEventMenuItem(cls, actionHandler, eventItems)));
var selectAll = new MenuItem("Select all events");
selectAll.setOnAction(e -> {
eventItems.values().forEach(item -> item.setSelected(true));
actionHandler.handle(e);
});
var deselectAll = new MenuItem("Deselect all events");
deselectAll.setOnAction(e -> {
eventItems.values().forEach(item -> item.setSelected(false));
actionHandler.handle(e);
});
getItems().addAll(
new SeparatorMenuItem(),
selectAll,
deselectAll
);
}
private MenuItem createEventMenuItem(Class<?> cls,
EventHandler<ActionEvent> actionHandler,
Map<Class<?>, CheckMenuItem> registry) {
var item = new CheckMenuItem(cls.getSimpleName());
item.setUserData(cls);
item.setOnAction(actionHandler);
item.setSelected(!PREDISABLED_EVENTS.contains(cls));
registry.put(cls, item);
return item;
}
}

View File

@ -0,0 +1,85 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.Preferences;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Objects;
import static devtoolsfx.scenegraph.attributes.AttributeCategory.REFLECTIVE;
@NullMarked
final class AttributeCellContent implements Comparable<AttributeCellContent> {
private final @Nullable AttributeCategory category;
private final @Nullable Attribute<?> attribute;
private AttributeCellContent(@Nullable AttributeCategory category,
@Nullable Attribute<?> attribute) {
this.category = category;
this.attribute = attribute;
}
@Nullable
AttributeCategory getCategory() {
return category;
}
@Nullable
Attribute<?> getAttribute() {
return attribute;
}
boolean isRoot() {
return category == null;
}
boolean isGroup() {
return category != null && attribute == null;
}
boolean matches(@Nullable String filter) {
return filter != null
&& attribute != null
&& attribute.name().toLowerCase().contains(filter.toLowerCase());
}
///////////////////////////////////////////////////////////////////////////
static AttributeCellContent forRoot() {
return new AttributeCellContent(null, null);
}
static AttributeCellContent forGroup(AttributeCategory category) {
Objects.requireNonNull(category, "category must be specified");
return new AttributeCellContent(category, null);
}
static AttributeCellContent forValue(Attribute<?> attribute) {
Objects.requireNonNull(attribute, "attribute must be specified");
return new AttributeCellContent(null, attribute);
}
@Override
public int compareTo(AttributeCellContent other) {
// reflective/summary attributes group should be the last one
if (isGroup() && other.isGroup() && other.getCategory() != null) {
if (getCategory() == REFLECTIVE) {
return 1;
}
return getCategory().compareTo(other.getCategory());
}
// all trackers (except reflective) sort attributes semantically,
// we can either keep this sorting or reset it to alphabetical order
if (getAttribute() != null && other.getAttribute() != null) {
return Preferences.KEEP_ATTRIBUTES_SORT
? 0
: getAttribute().name().compareTo(other.getAttribute().name());
}
return 0;
}
}

View File

@ -0,0 +1,147 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.Preferences;
import devtoolsfx.scenegraph.Element;
import javafx.css.PseudoClass;
import javafx.geometry.HPos;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Objects;
@NullMarked
final class AttributeDetailsPane extends ScrollPane {
private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty");
private static final String EMPTY_VALUE = "-";
private static final int MIN_NAME_WIDTH = 100;
private final AttributePane pane;
private final Hyperlink propertyTitleLink = new Hyperlink("Property");
private final Label propertyLabel = new Label();
private final Tooltip propertyTooltip = new Tooltip();
private final Hyperlink cssPropertyTitleLink = new Hyperlink("CSS property");
private final Label cssPropertyLabel = new Label();
private final Tooltip cssPropertyTooltip = new Tooltip();
private final Label defaultLabel = new Label();
AttributeDetailsPane(AttributePane pane) {
super();
this.pane = pane;
setId("attribute-details-pane");
createLayout();
initListeners();
}
void setContent(@Nullable AttributeCellContent content) {
if (content != null && content.getAttribute() != null) {
var attr = content.getAttribute();
propertyTitleLink.setText(attr.name());
toggleDocLink(
propertyTitleLink,
propertyTooltip,
createJavadocUri(Objects.requireNonNullElse(attr.field(), attr.name()))
);
propertyLabel.setText(defaultIfEmpty(String.valueOf(attr.value())));
cssPropertyLabel.setText(defaultIfEmpty(attr.cssProperty()));
toggleDocLink(cssPropertyTitleLink, cssPropertyTooltip, createCSSReferenceUri(attr.cssProperty()));
defaultLabel.setText(String.valueOf(attr.valueState()));
}
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
var grid = new GridPane();
grid.getStyleClass().add("grid");
grid.getColumnConstraints().setAll(
new ColumnConstraints(MIN_NAME_WIDTH, Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE, Priority.NEVER, HPos.LEFT, false),
new ColumnConstraints(Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE, Priority.ALWAYS, HPos.LEFT, true)
);
propertyLabel.setWrapText(true);
grid.add(propertyTitleLink, 0, 0);
grid.add(propertyLabel, 1, 0);
cssPropertyLabel.setWrapText(true);
grid.add(cssPropertyTitleLink, 0, 1);
grid.add(cssPropertyLabel, 1, 1);
defaultLabel.setWrapText(true);
grid.add(new Label("State"), 0, 2);
grid.add(defaultLabel, 1, 2);
setContent(grid);
setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
setHbarPolicy(ScrollBarPolicy.NEVER);
setFitToWidth(true);
setMaxHeight(10_000);
setPrefHeight(200);
}
private void initListeners() {
propertyTitleLink.setOnAction(e -> openDocLink(propertyTitleLink));
cssPropertyTitleLink.setOnAction(e -> openDocLink(cssPropertyTitleLink));
}
private @Nullable String createJavadocUri(String field) {
Element element = pane.getToolPane().getSelectedElement();
if (element == null || !element.getClassInfo().module().startsWith("javafx.")) {
return null;
}
String query = field;
String declaringClassName = pane.getToolPane().getConnector().getDeclaringClass(
element.getClassInfo().className(), field
);
if (declaringClassName != null) {
query = declaringClassName + "." + field;
}
return Preferences.JAVADOC_SEARCH_URI + "?q=" + query;
}
private @Nullable String createCSSReferenceUri(@Nullable String cssProperty) {
Element element = pane.getToolPane().getSelectedElement();
if (element == null || cssProperty == null || !element.getClassInfo().module().startsWith("javafx.")) {
return null;
}
return Preferences.CSS_REFERENCE_BASE_URI + "#" + element.getSimpleClassName().toLowerCase();
}
private void toggleDocLink(Hyperlink link, Tooltip tooltip, @Nullable String uri) {
link.setUserData(uri);
link.pseudoClassStateChanged(EMPTY, uri == null);
tooltip.setText(uri);
link.setTooltip(uri != null ? tooltip : null);
}
private void openDocLink(Hyperlink link) {
if (link.getUserData() != null && link.getUserData() instanceof String uri) {
pane.getToolPane().getPreferences().getHostServices().showDocument(uri);
}
}
private String defaultIfEmpty(@Nullable String s) {
return s != null && !s.isBlank() ? s : EMPTY_VALUE;
}
}

View File

@ -0,0 +1,118 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.gui.controls.FilterField;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.geometry.Orientation;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
@NullMarked
final class AttributePane extends SplitPane {
private static final int MIN_FILTER_LENGTH = 3;
private final ToolPane toolPane;
private final AttributeTreeTable table = new AttributeTreeTable();
private final FilterField filterField = new FilterField();
private final AttributeDetailsPane details = new AttributeDetailsPane(this);
AttributePane(ToolPane toolPane) {
super();
this.toolPane = toolPane;
createLayout();
initListeners();
}
void setAttributes(AttributeCategory category, List<Attribute<?>> attributes) {
table.setAttributes(category, attributes);
}
void updateAttribute(AttributeCategory category, Attribute<?> attribute) {
table.updateAttribute(category, attribute);
}
void clearAttributes() {
table.clear();
}
void setFilter(@Nullable String filter) {
table.setFilter(filter);
if (filter == null || filter.isBlank()) {
filterField.setText("");
}
}
ToolPane getToolPane() {
return toolPane;
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
filterField.setPromptText("property name");
HBox.setHgrow(filterField, Priority.ALWAYS);
// prevents field from changing height on SplitPane resize
filterField.setMinHeight(Region.USE_PREF_SIZE);
filterField.setMaxHeight(Region.USE_PREF_SIZE);
var filterBox = new HBox(filterField);
filterBox.getStyleClass().add("filter");
filterBox.setFillHeight(true);
VBox.setVgrow(filterBox, Priority.NEVER);
var tableBox = new VBox(table, filterBox);
tableBox.getStyleClass().add("table-box");
VBox.setVgrow(table, Priority.ALWAYS);
setId("attribute-pane");
getItems().setAll(tableBox);
setOrientation(Orientation.VERTICAL);
setDividerPositions(1);
setResizableWithParent(details, false);
}
private void initListeners() {
filterField.setOnKeyReleased(event -> {
var text = filterField.getText();
if (text.isEmpty() || text.length() >= MIN_FILTER_LENGTH) {
table.setFilter(text);
}
});
filterField.setOnClearButtonClick(() -> setFilter(null));
table.getSelectionModel().selectedItemProperty().addListener((obs, old, val) -> {
details.setContent(val != null ? val.getValue() : null);
if (val != null) {
if (!getItems().contains(details)) {
getItems().add(details);
setDividerPositions(0.8);
}
} else {
getItems().remove(details);
setDividerPositions(1.0);
}
});
table.setRefreshHandler(() -> {
String filter = table.getFilter();
toolPane.getConnector().reloadSelectedAttributes(null, null);
if (filter != null) {
table.setFilter(filter);
}
});
}
}

View File

@ -0,0 +1,89 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
@NullMarked
@SuppressWarnings("FieldCanBeLocal")
final class AttributeTreeItem extends TreeItem<AttributeCellContent> {
private final ObservableList<AttributeTreeItem> sourceList = FXCollections.observableArrayList();
private final FilteredList<AttributeTreeItem> filteredList = new FilteredList<>(sourceList);
private final SortedList<AttributeTreeItem> sortedList = new SortedList<>(
filteredList, Comparator.comparing(TreeItem::getValue)
);
AttributeTreeItem(AttributeCellContent value) {
super(value);
Bindings.bindContent(getChildren(), sortedList);
}
void addGroup(AttributeTreeItem group) {
sourceList.add(group);
}
boolean isGroupOf(AttributeCategory category) {
return getValue().getCategory() != null && getValue().getCategory() == category;
}
void setAttributes(List<Attribute<?>> attributes) {
List<AttributeTreeItem> items = attributes.stream()
.map(AttributeCellContent::forValue)
.map(AttributeTreeItem::new)
.toList();
sourceList.setAll(items);
}
void updateAttribute(Attribute<?> attribute) {
int index = -1;
for (int i = 0; i < sourceList.size(); i++) {
AttributeCellContent content = sourceList.get(i).getValue();
if (content.getAttribute() != null && Objects.equals(content.getAttribute().name(), attribute.name())) {
index = i;
break;
}
}
if (index > 0) {
sourceList.set(index, new AttributeTreeItem(AttributeCellContent.forValue(attribute)));
}
}
List<AttributeTreeItem> getChildrenUnmodifiable() {
return Collections.unmodifiableList(sourceList);
}
void clear() {
sourceList.clear();
}
boolean isEmpty() {
return getChildren().isEmpty();
}
void setFilterText(@Nullable String filter) {
if (filter == null) {
setFilterPredicate(null);
} else {
setFilterPredicate(item -> item.getValue().matches(filter));
}
}
void setFilterPredicate(@Nullable Predicate<? super AttributeTreeItem> predicate) {
filteredList.setPredicate(predicate);
}
}

View File

@ -0,0 +1,323 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.gui.controls.ColorIndicator;
import devtoolsfx.gui.util.Formatters;
import devtoolsfx.gui.util.GUIHelpers;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.Attribute.DisplayHint;
import devtoolsfx.scenegraph.attributes.Attribute.ValueState;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.TreeTableColumn.CellDataFeatures;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.util.Callback;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.text.DecimalFormat;
import java.util.List;
import java.util.Objects;
@NullMarked
final class AttributeTreeTable extends TreeTableView<AttributeCellContent> {
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.###");
private static final PseudoClass GROUP = PseudoClass.getPseudoClass("group");
private static final PseudoClass DEFAULT = PseudoClass.getPseudoClass("default");
private static final PseudoClass LEAF = PseudoClass.getPseudoClass("leaf");
private final AttributeTreeItem root = new AttributeTreeItem(AttributeCellContent.forRoot());
private @Nullable Runnable refreshHandler;
private @Nullable String filter;
AttributeTreeTable() {
super();
createTableColumns();
setColumnResizePolicy(CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
setContextMenu(createContextMenu());
setId("attribute-tree-table");
setRoot(root);
setShowRoot(false);
root.setExpanded(true);
setOnKeyPressed(e -> {
if (new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_ANY).match(e)) {
GUIHelpers.copySelectedRowsToClipboard(this, content ->
content.getAttribute() != null ? formatValueByText(content.getAttribute()).value() : ""
);
}
});
refreshRootFilter();
}
void setAttributes(AttributeCategory category,
List<Attribute<?>> attributes) {
AttributeTreeItem group = findGroupByCategory(category);
if (group == null) {
group = new AttributeTreeItem(AttributeCellContent.forGroup(category));
root.addGroup(group);
}
group.setAttributes(attributes);
group.setExpanded(true);
refreshRootFilter();
}
void updateAttribute(AttributeCategory category,
Attribute<?> attribute) {
AttributeTreeItem group = findGroupByCategory(category);
if (group == null) {
return;
}
group.updateAttribute(attribute);
}
void clear() {
filter = null;
root.clear();
refreshRootFilter();
}
@Nullable
String getFilter() {
return filter;
}
void setFilter(@Nullable String filter) {
this.filter = filter;
// we have to obtain children from the source list,
// not from the filtered list
for (var group : root.getChildrenUnmodifiable()) {
group.setFilterText(filter);
}
refreshRootFilter();
}
void setRefreshHandler(Runnable handler) {
this.refreshHandler = handler;
}
///////////////////////////////////////////////////////////////////////////
private void createTableColumns() {
var propertyCol = new TreeTableColumn<AttributeCellContent, AttributeCellContent>("Property");
var valueCol = new TreeTableColumn<AttributeCellContent, AttributeCellContent>("Value");
Callback<CellDataFeatures<AttributeCellContent, AttributeCellContent>, ObservableValue<AttributeCellContent>>
cellValueFactory = cdf -> new SimpleObjectProperty<>(cdf.getValue().getValue());
propertyCol.setCellValueFactory(cellValueFactory);
propertyCol.setCellFactory(c -> new TreeTableCell<>() {
final Label infoLabel = new Label();
{
getStyleClass().add("property-cell");
setContentDisplay(ContentDisplay.RIGHT);
infoLabel.getStyleClass().add("info");
}
@Override
protected void updateItem(@Nullable AttributeCellContent content, boolean empty) {
super.updateItem(content, empty);
if (empty || content == null) {
setGraphic(null);
infoLabel.setText(null);
setText(null);
getTableRow().pseudoClassStateChanged(GROUP, false);
return;
}
String text = null;
if (content.isGroup()) {
switch (content.getCategory()) {
case CONTROL -> text = "Control Properties";
case GRID_PANE -> text = "GridPane Properties";
case LABELED -> text = "Labeled Properties";
case IMAGE_VIEW -> text = "Image Properties";
case NODE -> text = "Node Properties";
case PARENT -> text = "Parent Properties";
case REGION -> text = "Region Properties";
case SCENE -> text = "Scene Properties";
case SHAPE -> text = "Shape Properties";
case TEXT -> text = "Text Properties";
case WINDOW -> text = "Window Properties";
case REFLECTIVE -> text = "Reflective Properties";
case null -> {
}
}
setGraphic(null);
infoLabel.setText(null);
} else if (content.getAttribute() != null) {
text = content.getAttribute().name();
var cssProperty = content.getAttribute().cssProperty();
if (cssProperty != null && !cssProperty.isEmpty()) {
setGraphic(infoLabel);
infoLabel.setText("CSS");
} else {
setGraphic(null);
infoLabel.setText(null);
}
}
pseudoClassStateChanged(LEAF, getTableRow().getTreeItem().isLeaf());
getTableRow().pseudoClassStateChanged(GROUP, content.isGroup());
setText(text);
}
});
propertyCol.setSortable(false);
propertyCol.setReorderable(false);
propertyCol.setMinWidth(200);
propertyCol.setPrefWidth(200);
propertyCol.setMaxWidth(300);
valueCol.setCellValueFactory(cellValueFactory);
valueCol.setCellFactory(c -> new TreeTableCell<>() {
@Override
protected void updateItem(@Nullable AttributeCellContent content, boolean empty) {
super.updateItem(content, empty);
if (empty || content == null || content.getAttribute() == null) {
setText(null);
setGraphic(null);
getTableRow().pseudoClassStateChanged(DEFAULT, false);
} else {
var cv = formatValueByText(content.getAttribute());
setText(cv.value());
getTableRow().pseudoClassStateChanged(DEFAULT, cv.isDefault());
setGraphic(getOptionalValueGraphic(content.getAttribute()));
}
}
});
valueCol.setSortable(false);
valueCol.setReorderable(false);
getColumns().add(propertyCol);
getColumns().add(valueCol);
}
private ContextMenu createContextMenu() {
var refresh = new MenuItem("Refresh");
refresh.setOnAction(e -> {
if (refreshHandler != null) {
refreshHandler.run();
}
});
var contextMenu = new ContextMenu();
contextMenu.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "attributeTableOptionsMenu");
contextMenu.getItems().addAll(refresh);
return contextMenu;
}
private @Nullable AttributeTreeItem findGroupByCategory(AttributeCategory category) {
for (var child : root.getChildren()) {
if (child instanceof AttributeTreeItem item && item.isGroupOf(category)) {
return item;
}
}
return null;
}
private void refreshRootFilter() {
// even though we're creating a new predicate instance each time,
// it won't work without setting it to null first
root.setFilterText(null);
root.setFilterPredicate(group -> !group.isEmpty());
}
private CellValue formatValueByText(Attribute<?> attribute) {
var value = attribute.value();
return switch (attribute.displayHint()) {
case COLOR -> {
if (value instanceof Color color) {
yield CellValue.of(
Formatters.colorToHexString(color) + "; " + Formatters.colorToRgbString(color),
attribute
);
}
yield CellValue.of(String.valueOf(value).toUpperCase(), attribute);
}
case INSETS -> {
if (value instanceof Insets insets && (Objects.equals(insets, Insets.EMPTY)
|| (insets.getTop() == 0 && insets.getRight() == 0 && insets.getBottom() == 0 && insets.getLeft() == 0))) {
yield new CellValue("Insets.EMPTY", true);
}
yield CellValue.of(String.valueOf(value), attribute);
}
case NUMERIC -> {
boolean isDefault = attribute.valueState() == ValueState.DEFAULT
|| (value instanceof Number num && num.doubleValue() == 0);
if (value instanceof Double num) {
if (num == Region.USE_COMPUTED_SIZE) {
yield new CellValue("USE_COMPUTED_SIZE", true);
}
if (num == Region.USE_PREF_SIZE) {
yield new CellValue("USE_PREF_SIZE", true);
}
if (num == Double.MIN_VALUE) {
yield new CellValue("MIN_VALUE", true);
}
if (num == Double.MAX_VALUE) {
yield new CellValue("MAX_VALUE", true);
}
yield new CellValue(DECIMAL_FORMAT.format(num), isDefault);
}
yield new CellValue(String.valueOf(value), isDefault);
}
default -> {
if (value instanceof List<?> list && list.isEmpty()) {
yield new CellValue("[]", true);
}
var s = String.valueOf(value);
boolean isDefault = attribute.valueState() == ValueState.DEFAULT || s.isEmpty() || "null".equals(s);
yield new CellValue(s, isDefault);
}
};
}
private @Nullable Node getOptionalValueGraphic(Attribute<?> attribute) {
if (attribute.displayHint() == DisplayHint.COLOR && attribute.value() instanceof Color color) {
return new ColorIndicator(color);
}
return null;
}
///////////////////////////////////////////////////////////////////////////
private record CellValue(String value, boolean isDefault) {
public static CellValue of(String value, Attribute<?> attribute) {
return new CellValue(value, attribute.valueState() == ValueState.DEFAULT);
}
}
}

View File

@ -0,0 +1,74 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.scenegraph.Element;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.NullMarked;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
/**
* Accumulates expand/collapse logic for the tree items.
*/
@NullMarked
final class ExpandCollapse<T> {
private final Set<T> expandItems = new HashSet<>();
private final Set<T> collapseItems = new HashSet<>();
private final Function<TreeItem<Element>, T> fun;
public ExpandCollapse(Function<TreeItem<Element>, T> fun) {
this.fun = fun;
}
/**
* Toggles the expand/collapse state of the given item. If the item is already
* present, it will be removed; otherwise, it will be added to either the expanded
* or collapsed items, depending on the toggle flag.
*/
void toggle(TreeItem<Element> item, boolean expand) {
var t = fun.apply(item);
if (expand) {
if (expandItems.contains(t)) {
expandItems.remove(t);
} else {
expandItems.add(t);
}
} else {
if (collapseItems.contains(t)) {
collapseItems.remove(t);
} else {
collapseItems.add(t);
}
}
}
/**
* Shortcut for {@link #toggle(TreeItem, boolean)}.
*/
void expand(TreeItem<Element> item) {
toggle(item, true);
}
/**
* Shortcut for {@link #toggle(TreeItem, boolean)}.
*/
void collapse(TreeItem<Element> item) {
toggle(item, false);
}
/**
* Checks whether the given item is present in the "expanded list".
*/
boolean isExpanded(TreeItem<Element> item) {
return expandItems.contains(fun.apply(item));
}
/**
* Checks whether the given item is present in the "collapsed list".
*/
boolean isCollapsed(TreeItem<Element> item) {
return collapseItems.contains(fun.apply(item));
}
}

View File

@ -0,0 +1,134 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.attributes.Attribute;
import devtoolsfx.scenegraph.attributes.AttributeCategory;
import javafx.scene.control.SplitPane;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
@NullMarked
public final class InspectorTab extends SplitPane {
public static final String TAB_NAME = "Inspector";
private final SceneGraphPane sceneGraphPane;
private final AttributePane attributePane;
public InspectorTab(ToolPane toolPane) {
super();
sceneGraphPane = new SceneGraphPane(toolPane);
attributePane = new AttributePane(toolPane);
createLayout();
}
/**
* Adds or updates a window element in the scenegraph tree.
*/
public void addOrUpdateWindow(Element element) {
sceneGraphPane.addOrUpdateWindow(element);
}
/**
* Removes window element from the scenegraph tree.
*/
public void removeWindow(int uid) {
sceneGraphPane.removeWindow(uid);
}
/**
* Returns the UID of the window containing the specified element, or zero if not found.
*/
public int getWindow(Element element) {
return sceneGraphPane.getWindow(element);
}
/**
* Adds a new node element to the scenegraph tree.
*/
public void addTreeElement(Element element) {
sceneGraphPane.addTreeElement(element);
}
/**
* Adds node element from the scenegraph tree.
*/
public void removeTreeElement(Element element) {
sceneGraphPane.removeTreeElement(element);
}
/**
* Returns the selected element from the scenegraph tree.
*/
@Nullable
public Element getSelectedTreeElement() {
return sceneGraphPane.getSelectedTreeElement();
}
/**
* Selects the specified element in the scenegraph tree.
*/
public void selectTreeElement(Element element) {
sceneGraphPane.selectTreeElement(element);
}
/**
* Clears the scenegraph tree selection.
*/
@SuppressWarnings("unused")
public void clearTreeSelection() {
sceneGraphPane.clearTreeSelection();
}
/**
* Updates the specified element visibility state in the scenegraph tree.
*/
public void updateTreeElementVisibilityState(Element element, boolean visibility) {
sceneGraphPane.updateTreeElementVisibilityState(element, visibility);
}
/**
* Updates the specified element style class list in the scenegraph tree.
*/
public void updateTreeElementStyleClass(Element element, List<String> styleClass) {
sceneGraphPane.updateTreeElementStyleClass(element, styleClass);
}
/**
* Sets (replaces) the list of displayed attributes.
*/
public void setAttributes(AttributeCategory category, List<Attribute<?>> attributes) {
attributePane.setAttributes(category, attributes);
}
/**
* Updates a single attribute value.
*/
public void updateAttribute(AttributeCategory category, Attribute<?> attribute) {
attributePane.updateAttribute(category, attribute);
}
/**
* Clears the list of displayed attributes.
*/
public void clearAttributes() {
attributePane.clearAttributes();
attributePane.setFilter("");
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
setId("inspector-tab");
getStyleClass().add("tab");
setDividerPositions(0.4);
SplitPane.setResizableWithParent(sceneGraphPane, false);
getItems().addAll(sceneGraphPane, attributePane);
}
}

View File

@ -0,0 +1,120 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.scenegraph.Element;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
@NullMarked
final class SceneGraphPane extends VBox {
private final SearchField<TreeItem<Element>> searchField = new SearchField<>();
private final SceneGraphTree tree;
SceneGraphPane(ToolPane toolPane) {
super();
tree = new SceneGraphTree(toolPane);
createLayout();
initListeners();
}
void addOrUpdateWindow(Element element) {
tree.addOrUpdateWindow(element);
}
void removeWindow(int uid) {
tree.removeWindow(uid);
}
int getWindow(Element element) {
return tree.getWindow(element);
}
void addTreeElement(Element element) {
tree.addElement(element);
}
void removeTreeElement(Element element) {
tree.removeElement(element);
}
@Nullable
Element getSelectedTreeElement() {
return tree.getSelectedElement();
}
void selectTreeElement(Element element) {
tree.selectElement(element);
}
void clearTreeSelection() {
tree.getSelectionModel().clearSelection();
}
@SuppressWarnings("unused")
void updateTreeElementVisibilityState(Element element, boolean visibility) {
tree.updateTreeElement(element);
tree.refresh();
}
@SuppressWarnings("unused")
void updateTreeElementStyleClass(Element element, List<String> styleClass) {
tree.updateTreeElement(element);
tree.refresh();
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
HBox.setHgrow(searchField, Priority.ALWAYS);
var filterBox = new HBox(searchField);
filterBox.getStyleClass().add("filter");
VBox.setVgrow(tree, Priority.ALWAYS);
setId("scenegraph-pane");
getChildren().setAll(tree, filterBox);
}
private void initListeners() {
searchField.setOnTextChange(() -> {
var filter = searchField.getText();
if (filter.length() <= 3) {
clearSearchResult();
return;
}
List<TreeItem<Element>> result = tree.search(filter);
searchField.setNavigableResult(result);
if (!result.isEmpty()) {
searchField.navigateNext();
}
tree.refresh();
});
searchField.setNavigationHandler((position, item) -> tree.navigate(item));
searchField.setOnClearButtonClick(() -> {
searchField.setText(null);
clearSearchResult();
});
}
private void clearSearchResult() {
tree.search(null);
searchField.setNavigableResult(null);
tree.refresh();
}
}

View File

@ -0,0 +1,528 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.connector.Connector;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.gui.Preferences;
import devtoolsfx.gui.util.DummyElement;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.WindowProperties.WindowType;
import javafx.scene.control.*;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.util.*;
import java.util.stream.Collectors;
@NullMarked
final class SceneGraphTree extends TreeView<Element> {
private static final Logger LOGGER = System.getLogger(SceneGraphTree.class.getName());
private final ToolPane toolPane;
private final TreeItem<Element> treeRoot;
private final Map<Element, TreeItem<Element>> treeIndex = new HashMap<>();
private final ExpandCollapse<Integer> forcedNodes = new ExpandCollapse<>(
item -> item.getValue().hashCode()
);
private final ExpandCollapse<String> forcedTypes = new ExpandCollapse<>(
item -> item.getValue().getSimpleClassName()
);
private @Nullable ContextMenu contextMenu;
private boolean blockSelection;
SceneGraphTree(ToolPane toolPane) {
this.toolPane = toolPane;
treeRoot = new SceneGraphTreeItem(new DummyElement(""));
treeRoot.setExpanded(true);
setId("scene-graph-tree");
setRoot(treeRoot);
setShowRoot(false);
setCellFactory(c -> new SceneGraphTreeCell());
initListeners();
}
///////////////////////////////////////////////////////////////////////////
// Public API //
///////////////////////////////////////////////////////////////////////////
/**
* Adds a new window element or updates the existing one with a new root.
*/
void addOrUpdateWindow(Element element) {
if (!element.isWindowElement()) {
LOGGER.log(Level.WARNING, "Adding or updating a window is only possible with a window type of element");
return;
}
// clear selection if the currently selected item belongs to the replaced window branch
boolean clearSelection = false;
if (!getSelectionModel().isEmpty()) {
var window = findParentWindowItem(getSelectionModel().getSelectedItem());
if (window != null && Objects.equals(window.getValue(), element)) {
clearSelection = true;
}
}
blockSelection = true;
treeRoot.getChildren().removeIf(item ->
item.getValue().isWindowElement() && Objects.equals(item.getValue(), element)
);
treeRoot.getChildren().add(createTreeBranch(element));
blockSelection = false;
if (clearSelection) {
clearConnectorSelection();
}
}
/**
* Removes the window element (the entire branch) from the tree.
*/
void removeWindow(int uid) {
blockSelection = true;
treeRoot.getChildren().removeIf(
item -> item.getValue().isWindowElement() && item.getValue().getUID() == uid
);
blockSelection = false;
}
/**
* Returns the UID of the window containing the specified element, or zero if not found.
*/
int getWindow(Element element) {
var item = treeIndex.get(element);
if (item == null) {
return 0;
}
TreeItem<Element> window = findParentWindowItem(item);
return window != null ? window.getValue().getUID() : 0;
}
/**
* Selects the specified element in the tree and scrolls to it.
*/
void selectElement(Element element) {
if (treeIndex.containsKey(element)) {
getSelectionModel().select(treeIndex.get(element));
scrollTo(getSelectionModel().getSelectedIndex());
}
}
/**
* Returns the value of the selected tree item.
*/
@Nullable
Element getSelectedElement() {
var item = getSelectionModel().getSelectedItem();
return item != null ? item.getValue() : null;
}
/**
* Adds the given element to the tree.
*/
void addElement(Element element) {
blockSelection = true;
doAddElement(element);
blockSelection = false;
}
/**
* Removes the given element to the tree.
*/
void removeElement(Element element) {
blockSelection = true;
doRemoveElement(element);
blockSelection = false;
}
/**
* Updates the specified element.
*/
void updateTreeElement(Element element) {
var item = treeIndex.get(element);
if (item != null) {
item.setValue(element);
}
}
/**
* Searches the tree items for the specified string.
*/
List<TreeItem<Element>> search(@Nullable String filter) {
// reset previous filter
treeIndex.forEach((element, item) -> {
if (item instanceof SceneGraphTreeItem sg) {
sg.setFiltered(false);
}
});
if (filter == null) {
return List.of();
}
return treeIndex.entrySet().stream()
.filter(e -> isMatchFilter(e.getKey(), filter))
.map(e -> (SceneGraphTreeItem) e.getValue())
.peek(item -> item.setFiltered(true))
.sorted()
.collect(Collectors.toList());
}
/**
* Navigates the tree to the specified element, expanding the branch if needed
* and scrolling to the element's position.
*/
void navigate(TreeItem<Element> item) {
// getRow() returns _visible_ row index, so we have to expand branch first
expandBranchUpward(item);
int index = getRow(item);
if (index > 0) {
scrollTo(index);
}
}
///////////////////////////////////////////////////////////////////////////
// Internal //
///////////////////////////////////////////////////////////////////////////
private void initListeners() {
getSelectionModel().selectedItemProperty().addListener((obs, old, val) -> {
if (blockSelection) {
return;
}
if (val != null) {
selectConnectorElement(val);
} else {
clearConnectorSelection();
}
});
setOnMousePressed(event -> {
if (contextMenu != null) {
contextMenu.hide();
contextMenu = null;
}
if (event.isSecondaryButtonDown() && getSelectionModel().getSelectedItem() != null) {
contextMenu = createContextMenu(getSelectionModel().getSelectedItem());
contextMenu.show(SceneGraphTree.this, event.getScreenX(), event.getScreenY());
}
});
}
private ContextMenu createContextMenu(TreeItem<Element> item) {
boolean isLeaf = item.getChildren().isEmpty();
Element element = item.getValue();
var contextMenu = new ContextMenu();
contextMenu.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "sceneGraphTreeContextMenu");
var clearSelectionMenu = new MenuItem("Clear selection");
clearSelectionMenu.setOnAction(e -> clearConnectorSelection());
var hidePopupsMenu = new MenuItem("Close popup windows");
hidePopupsMenu.setOnAction(e -> closePopupWindows());
var expandNodeMenu = new CheckMenuItem("Keep expanded");
expandNodeMenu.setOnAction(e -> forcedNodes.expand(item));
expandNodeMenu.setSelected(forcedNodes.isExpanded(item));
expandNodeMenu.setDisable(isLeaf);
var collapseNodeMenu = new CheckMenuItem("Keep collapsed");
collapseNodeMenu.setOnAction(e -> forcedNodes.collapse(item));
collapseNodeMenu.setSelected(forcedNodes.isCollapsed(item));
collapseNodeMenu.setDisable(isLeaf);
var expandTypeMenu = new CheckMenuItem("Always expand " + element.getSimpleClassName());
expandTypeMenu.setOnAction(e -> forcedTypes.expand(item));
expandTypeMenu.setSelected(forcedTypes.isExpanded(item));
var collapseTypeMenu = new CheckMenuItem("Always collapse " + element.getSimpleClassName());
collapseTypeMenu.setOnAction(e -> forcedTypes.collapse(item));
collapseTypeMenu.setSelected(forcedTypes.isCollapsed(item));
contextMenu.getItems().addAll(
expandNodeMenu,
collapseNodeMenu,
expandTypeMenu,
collapseTypeMenu,
new SeparatorMenuItem(),
clearSelectionMenu,
hidePopupsMenu
);
return contextMenu;
}
/**
* Notifies the {@link Connector} that the selected {@link Element} in the UI has been changed.
*/
private void selectConnectorElement(TreeItem<Element> item) {
TreeItem<Element> window = findParentWindowItem(item);
if (window != null) {
toolPane.getConnector().selectElement(window.getValue().getUID(), item.getValue());
}
}
/**
* See {@link #selectConnectorElement(TreeItem)}.
*/
private void clearConnectorSelection() {
var window = findParentWindowItem(getSelectionModel().getSelectedItem());
if (window != null) {
getSelectionModel().clearSelection();
toolPane.getConnector().clearSelection(window.getValue().getUID());
}
}
/**
* Creates a tree branch starting from the given {@link Element} and traversing
* deep down to its latest descendant. During this path, it sets the expanded flag
* for every tree item along the way.
*/
private TreeItem<Element> createTreeBranch(Element element) {
var branch = new SceneGraphTreeItem(element);
treeIndex.put(element, branch);
// recursively create child tree items for the given node
var children = new ArrayList<TreeItem<Element>>(element.getChildren().size());
for (var child : element.getChildren()) {
if (!child.isAuxiliaryElement()) {
children.add(createTreeBranch(child));
}
}
for (TreeItem<Element> child : children) {
branch.getChildren().add(child);
}
// determine the tree item's expanded state, it's collapsed by default
if (!forcedNodes.isCollapsed(branch) && !forcedTypes.isCollapsed(branch)) {
boolean forceState = forcedNodes.isExpanded(branch) || forcedTypes.isExpanded(branch);
branch.setExpanded(isElementPreferToBeExpanded(element) || forceState);
}
return branch;
}
/**
* Finds and returns the closest window item for the given {@link TreeItem<Element>}.
* Returns {@code null} if not found.
*/
private @Nullable TreeItem<Element> findParentWindowItem(@Nullable TreeItem<Element> item) {
if (item == null) {
return null;
}
return item.getValue().isWindowElement() ? item : findParentWindowItem(item.getParent());
}
/**
* See {@link #addElement(Element)}.
*/
private void doAddElement(Element elementToAdd) {
if (elementToAdd.isAuxiliaryElement()) {
return;
}
// get the parent node and if not, it's impossible to determine new node position
Element parentElement = elementToAdd.getParent();
if (parentElement == null) {
LOGGER.log(Level.WARNING, "Unable to determine element position as it has no parent");
return;
}
TreeItem<Element> parentItem = treeIndex.get(parentElement);
if (parentItem == null) {
LOGGER.log(Level.WARNING, "Unable to determine tree item position as it has no parent");
return;
}
// guard block, should never happen
if (containsElement(parentItem, elementToAdd)) {
LOGGER.log(Level.WARNING, "Unable to add element as it is already present");
return;
}
TreeItem<Element> selectedItem = getSelectionModel().getSelectedItem();
TreeItem<Element> itemToAdd = createTreeBranch(elementToAdd);
List<Element> siblingElements = parentElement.getChildren();
List<TreeItem<Element>> siblingItems = parentItem.getChildren();
// where we want to be
int targetPos = siblingElements.indexOf(elementToAdd);
boolean posFound = false;
int prevPos = -1;
// try to insert the tree item in its real position
for (int index = 0; index < siblingItems.size(); index++) {
var currentElement = siblingItems.get(index).getValue();
int currentPos = siblingElements.indexOf(currentElement);
// guard block, should never happen
if (prevPos > currentPos) {
LOGGER.log(Level.WARNING, String.format(
"Unexpected condition encountered while adding tree node: parent %s=%d, child %s=%d",
parentElement.getSimpleClassName(), prevPos,
currentElement.getSimpleClassName(), currentPos
));
}
if (targetPos > prevPos && targetPos < currentPos) {
parentItem.getChildren().add(index, itemToAdd);
posFound = true;
break;
}
prevPos = currentPos;
}
// if the target position is not found, add to the end
if (!posFound) {
parentItem.getChildren().add(itemToAdd);
}
// restore selection
if (selectedItem != null) {
getSelectionModel().select(selectedItem);
}
}
/**
* See {@link #removeElement(Element)}.
*/
private void doRemoveElement(Element elementToRemove) {
if (elementToRemove.isAuxiliaryElement()) {
return;
}
var itemToRemove = treeIndex.get(elementToRemove);
if (itemToRemove == null) {
var className = elementToRemove.getSimpleClassName();
LOGGER.log(Level.WARNING, "Trying to remove a non-existent tree item: " + className);
return;
}
// preserve the selected item or clear the selection, if it's the very
// item that needs to be removed
TreeItem<Element> selectedItem = getSelectionModel().getSelectedItem();
if (selectedItem != null && selectedItem.getValue() == elementToRemove) {
var window = findParentWindowItem(itemToRemove);
if (window != null) {
getSelectionModel().clearSelection();
toolPane.getConnector().clearSelection(window.getValue().getUID());
selectedItem = null;
}
}
// do not use directly the list as it will suffer concurrent modifications
List<TreeItem<Element>> childrenToRemove = itemToRemove.getChildren();
for (var child : new ArrayList<>(childrenToRemove)) {
// recursively remove the whole branch
doRemoveElement(child.getValue());
}
// finally, remove the target tree item itself
if (itemToRemove.getParent() != null) {
itemToRemove.getParent().getChildren().remove(itemToRemove);
}
treeIndex.remove(itemToRemove.getValue());
// restore selection
if (selectedItem != null) {
getSelectionModel().select(selectedItem);
}
}
/**
* Checks whether the given tree item contains the specified element in its children list.
*/
private boolean containsElement(@Nullable TreeItem<Element> parentItem, Element element) {
if (parentItem == null) {
return false;
}
for (TreeItem<Element> node : parentItem.getChildren()) {
if (Objects.equals(node.getValue(), element)) {
return true;
}
}
return false;
}
/**
* Checks the preferred expand/collapse state of the specified element.
*/
private boolean isElementPreferToBeExpanded(Element element) {
Preferences preferences = toolPane.getPreferences();
boolean preferToBeExpanded = true;
if (element.isNodeElement()) {
var props = element.getNodeProperties();
if (props != null && props.isControl() && preferences.isCollapseControls()) {
preferToBeExpanded = false;
}
if (props != null && props.isPane() && preferences.getCollapsePanes()) {
preferToBeExpanded = false;
}
}
return preferToBeExpanded;
}
/**
* Expands the entire branch, starting from the specified node and continuing up to the root.
*/
private void expandBranchUpward(TreeItem<Element> item) {
var parent = item.getParent();
if (parent != null) {
parent.setExpanded(true);
expandBranchUpward(parent);
}
}
/**
* Checks whether the given element matches the filter.
*/
private boolean isMatchFilter(Element element, String filter) {
if (element.isWindowElement()) {
return false;
}
var props = element.getNodeProperties();
if (props == null) {
return false;
}
return containsIgnoreCase(element.getSimpleClassName(), filter)
|| containsIgnoreCase(props.id(), filter)
|| props.styleClass().stream().anyMatch(styleClass -> containsIgnoreCase(styleClass, filter));
}
/**
* Requests the connector to close (hide) all popup windows.
*/
private void closePopupWindows() {
List<Element> windows = treeRoot.getChildren().stream()
.map(TreeItem::getValue)
.filter(e -> e.getWindowProperties() != null && e.getWindowProperties().windowType() == WindowType.POPUP)
.toList();
// prevents ConcurrentModificationException
windows.forEach(e -> toolPane.getConnector().hideWindow(e.getUID()));
}
private boolean containsIgnoreCase(@Nullable String str, @Nullable String subStr) {
return str != null && (subStr == null || str.toLowerCase().contains(subStr.toLowerCase()));
}
}

View File

@ -0,0 +1,63 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.util.Formatters;
import devtoolsfx.scenegraph.Element;
import javafx.css.PseudoClass;
import javafx.scene.control.Label;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
final class SceneGraphTreeCell extends TreeCell<Element> {
static final PseudoClass HIDDEN = PseudoClass.getPseudoClass("hidden");
static final PseudoClass FILTERED = PseudoClass.getPseudoClass("filtered");
private final Label label = new Label();
public SceneGraphTreeCell() {
super();
}
@Override
@SuppressWarnings("ConstantValue")
public void updateItem(Element element, boolean empty) {
super.updateItem(element, empty);
if (empty || element == null) {
pseudoClassStateChanged(HIDDEN, false);
pseudoClassStateChanged(FILTERED, false);
label.setText(null);
setText(null);
setGraphic(null);
return;
}
label.setText(Formatters.formatForTreeItem(element));
setGraphic(label);
pseudoClassStateChanged(HIDDEN, isHidden(element));
pseudoClassStateChanged(FILTERED, isFiltered(getTreeItem()));
}
private boolean isHidden(@Nullable Element element) {
if (element == null || !element.isNodeElement()) {
return false;
}
var props = element.getNodeProperties();
if (props != null && !props.isVisible()) {
return true;
}
return isHidden(element.getParent());
}
private boolean isFiltered(@Nullable TreeItem<?> item) {
return item instanceof SceneGraphTreeItem sg && sg.getFiltered();
}
}

View File

@ -0,0 +1,68 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.scenegraph.Element;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.NullMarked;
import java.util.ArrayList;
import java.util.List;
@NullMarked
final class SceneGraphTreeItem extends TreeItem<Element> implements Comparable<TreeItem<Element>> {
private final BooleanProperty filtered = new SimpleBooleanProperty();
SceneGraphTreeItem(Element value) {
super(value);
}
boolean getFiltered() {
return filtered.get();
}
void setFiltered(boolean filtered) {
this.filtered.set(filtered);
}
@Override
public int compareTo(TreeItem<Element> other) {
var thisPath = getPathIndices(this, new ArrayList<>()).reversed();
var otherPath = getPathIndices(other, new ArrayList<>()).reversed();
// a guard block, should not happen if not comparing with root
if (thisPath.isEmpty() && !otherPath.isEmpty()) {
return -1;
}
if (otherPath.isEmpty() && !thisPath.isEmpty()) {
return 1;
}
for (int i = 0; i <= Math.min(thisPath.size(), otherPath.size()) - 1; i++) {
// lesser number wins, because it's closer to the root
int result = Integer.compare(thisPath.get(i), otherPath.get(i));
if (result != 0) {
return result;
}
}
return 0;
}
/**
* Returns a list of indices that represent the position of the given item in the tree.
* The list is in reverse order, from leaf to root.
*/
private List<Integer> getPathIndices(TreeItem<Element> item, List<Integer> accumulator) {
var parent = item.getParent();
if (parent == null) {
return accumulator;
}
accumulator.add(parent.getChildren().indexOf(item));
getPathIndices(parent, accumulator);
return accumulator;
}
}

View File

@ -0,0 +1,160 @@
package devtoolsfx.gui.inspector;
import devtoolsfx.gui.controls.TextInputContextMenu;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TextField;
import javafx.scene.layout.*;
import javafx.scene.text.Text;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
@NullMarked
final class SearchField<T> extends HBox {
private final TextField textField = new TextField();
private final HBox controlsBox = new HBox();
private final Text hintText = new Text();
private final Button clearButton = new Button("clear");
private final Button upButton = new Button();
private final Button downButton = new Button();
private List<T> navigableResult = List.of();
private int position = -1;
private @Nullable BiConsumer<Integer, T> navigationHandler;
SearchField() {
super();
setId("scene-graph-search-field");
createLayout();
initListeners();
}
String getText() {
return textField.getText() != null ? textField.getText().trim() : "";
}
@SuppressWarnings("SameParameterValue")
void setText(@Nullable String text) {
textField.setText(text != null ? text.trim() : "");
}
void setOnTextChange(@Nullable Runnable handler) {
if (handler != null) {
textField.setOnKeyReleased(event -> handler.run());
}
}
void setOnClearButtonClick(@Nullable Runnable handler) {
if (handler != null) {
clearButton.setOnMousePressed(event -> handler.run());
}
}
void setNavigableResult(@Nullable List<T> result) {
this.navigableResult = Objects.requireNonNullElse(result, List.of());
controlsBox.setVisible(!isFieldClear());
controlsBox.setManaged(!isFieldClear());
position = -1; // ready to be incremented
upButton.setDisable(navigableResult.size() <= 1);
downButton.setDisable(navigableResult.size() <= 1);
updateHintText();
}
void setNavigationHandler(@Nullable BiConsumer<Integer, T> navigationHandler) {
this.navigationHandler = navigationHandler;
}
void navigatePrevious() {
if (navigationHandler != null) {
position = position - 1;
if (position < 0) {
position = navigableResult.size() - 1;
}
navigationHandler.accept(position, navigableResult.get(position));
updateHintText();
}
}
void navigateNext() {
if (navigationHandler != null) {
position = position + 1;
if (position > navigableResult.size() - 1) {
position = 0;
}
navigationHandler.accept(position, navigableResult.get(position));
updateHintText();
}
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
Supplier<Pane> iconGenerator = () -> {
var pane = new StackPane();
pane.getStyleClass().add("icon");
return pane;
};
textField.setPromptText("id or styleClass or nodeName");
textField.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(textField, Priority.ALWAYS);
textField.setContextMenu(new TextInputContextMenu(textField));
clearButton.setGraphic(iconGenerator.get());
clearButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
clearButton.getStyleClass().add("clear-button");
hintText.getStyleClass().add("hint");
upButton.setGraphic(iconGenerator.get());
upButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
upButton.getStyleClass().addAll("arrow-button", "up-button");
downButton.setGraphic(iconGenerator.get());
downButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
downButton.getStyleClass().addAll("arrow-button", "down-button");
controlsBox.getStyleClass().add("controls");
controlsBox.getChildren().setAll(clearButton, hintText, upButton, downButton);
controlsBox.setAlignment(Pos.CENTER_RIGHT);
HBox.setHgrow(controlsBox, Priority.NEVER);
controlsBox.setVisible(false);
controlsBox.setManaged(false);
setAlignment(Pos.CENTER_LEFT);
getChildren().addAll(textField, controlsBox);
}
private void initListeners() {
upButton.setOnAction(e -> navigatePrevious());
downButton.setOnAction(e -> navigateNext());
clearButton.disableProperty().bind(textField.textProperty().isEmpty());
}
private void updateHintText() {
if (isFieldClear()) {
hintText.setText("");
return;
}
hintText.setText(String.format("%d of %d", position + 1, navigableResult.size()));
}
private boolean isFieldClear() {
return (textField.getText() == null || textField.getText().isBlank()) && navigableResult.isEmpty();
}
}

View File

@ -0,0 +1,154 @@
package devtoolsfx.gui.preferences;
import devtoolsfx.gui.Preferences;
import devtoolsfx.gui.ToolPane;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public class PreferencesTab extends VBox {
public static final String TAB_NAME = "Preferences";
private final ToolPane toolPane;
public PreferencesTab(ToolPane toolPane) {
super();
this.toolPane = toolPane;
createLayout();
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
getChildren().setAll(
createSceneGraphGroup(),
createInspectionGroup(),
createEventLogGroup()
);
setId("preferences-tab");
getStyleClass().setAll("tab");
}
private VBox createSceneGraphGroup() {
var autoRefreshToggle = new CheckBox("Refresh automatically");
autoRefreshToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().autoRefreshSceneGraphProperty()
);
autoRefreshToggle.setDisable(true);
var preventAutoHideToggle = new CheckBox("Prevent auto-hide for popup windows");
preventAutoHideToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().preventPopupAutoHideProperty()
);
var collapseControlsToggle = new CheckBox("Collapse controls");
collapseControlsToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().collapseControlsProperty()
);
var collapsePanesToggle = new CheckBox("Collapse panes");
collapsePanesToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().collapsePanesProperty()
);
var content = new FlowPane(
autoRefreshToggle,
preventAutoHideToggle,
collapseControlsToggle,
collapsePanesToggle
);
return createPreferencesGroup("Scene Graph", content);
}
private VBox createInspectionGroup() {
var layoutBoundsToggle = new CheckBox("Highlight layout bounds");
layoutBoundsToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().showLayoutBoundsProperty()
);
var boundsInParentToggle = new CheckBox("Highlight bounds in parent");
boundsInParentToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().showBoundsInParentProperty()
);
var baselineToggle = new CheckBox("Highlight baseline offset");
baselineToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().showBaselineProperty()
);
var mouseTransparentToggle = new CheckBox("Ignore mouse transparent");
mouseTransparentToggle.selectedProperty().bindBidirectional(
toolPane.getPreferences().ignoreMouseTransparentProperty()
);
var content = new FlowPane(
layoutBoundsToggle,
boundsInParentToggle,
baselineToggle,
mouseTransparentToggle
);
return createPreferencesGroup("Inspection", content);
}
private VBox createEventLogGroup() {
var maxSizeField = new TextField(String.valueOf(
toolPane.getPreferences().getMaxEventLogSize()
));
maxSizeField.textProperty().addListener(
(obs, old, val) -> toolPane.getPreferences().setMaxEventLogSize(parseEventLogSize(val))
);
var maxSizeBox = new HBox(new Label("Max log size"), maxSizeField);
maxSizeBox.setSpacing(8);
maxSizeBox.setAlignment(Pos.CENTER_LEFT);
var content = new FlowPane(
maxSizeBox
);
return createPreferencesGroup("Event Log", content);
}
private VBox createPreferencesGroup(String name, Node content) {
var header = new Label(name);
header.getStyleClass().add("header");
content.getStyleClass().add("content");
var group = new VBox(header, content);
group.getStyleClass().add("group");
return group;
}
private int parseEventLogSize(@Nullable String text) {
int nextVal = Preferences.DEFAULT_EVENT_LOG_SIZE;
if (text != null && !text.isBlank()) {
try {
nextVal = Integer.parseInt(text);
} catch (NumberFormatException ignored) {
}
}
if (nextVal < Preferences.MIN_EVENT_LOG_SIZE || nextVal > Preferences.MAX_EVENT_LOG_SIZE) {
nextVal = Preferences.DEFAULT_EVENT_LOG_SIZE;
}
return nextVal;
}
}

View File

@ -0,0 +1,24 @@
package devtoolsfx.gui.style;
import java.util.Base64;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* A wrapper for the stylesheet URI that provides additional information
* and utility methods.
*/
record Stylesheet(String uri, boolean isUserAgentStylesheet) {
public static final String DATA_URI_PREFIX = "data:base64,";
boolean isDataURI() {
return uri.startsWith(DATA_URI_PREFIX);
}
String decodeFromDataURI() {
return new String(Base64.getDecoder().decode(
uri.substring(DATA_URI_PREFIX.length()).getBytes(UTF_8)
), UTF_8);
}
}

View File

@ -0,0 +1,161 @@
package devtoolsfx.gui.style;
import devtoolsfx.connector.ConnectorOptions;
import devtoolsfx.gui.ToolPane;
import devtoolsfx.gui.controls.Dialog;
import devtoolsfx.gui.controls.TextView;
import devtoolsfx.gui.util.Formatters;
import devtoolsfx.scenegraph.Element;
import devtoolsfx.scenegraph.NodeProperties;
import devtoolsfx.scenegraph.WindowProperties;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Map;
@NullMarked
public final class StylesheetTab extends VBox {
public static final String TAB_NAME = "Stylesheets";
static final int ROOT_UID = -1;
static final String ROOT_ITEM_NAME = "Application";
private final ToolPane toolPane;
private final TreeView<String> treeView = new TreeView<>();
private @Nullable Dialog<TextView> textViewDialog = null;
public StylesheetTab(ToolPane toolPane) {
super();
this.toolPane = toolPane;
createLayout();
}
public void update() {
var treeRoot = StylesheetTreeItem.of(ROOT_UID, getPlatformUserAgentStylesheet(), true);
treeRoot.setExpanded(true);
for (int uid : toolPane.getConnector().getMonitorIdentifiers()) {
Map.@Nullable Entry<WindowProperties, List<Element>> data = toolPane.getConnector().getStyledElements(uid);
if (data == null || (data.getKey().sceneStylesheets().isEmpty() && data.getValue().isEmpty())) {
continue;
}
WindowProperties windowProps = data.getKey();
var window = StylesheetTreeItem.of(uid, Formatters.formatForTreeItem(uid, windowProps));
window.setExpanded(true);
if (windowProps.userAgentStylesheet() != null || !windowProps.sceneStylesheets().isEmpty()) {
window.getChildren().add(createTreeItem(
uid,
"Scene",
windowProps.sceneStylesheets(),
windowProps.userAgentStylesheet()
));
}
for (Element element : data.getValue()) {
if (!element.isNodeElement() || element.getNodeProperties() == null) {
continue;
}
NodeProperties nodeProps = element.getNodeProperties();
window.getChildren().add(createTreeItem(
uid,
Formatters.formatForTreeItem(element.getSimpleClassName(), nodeProps),
nodeProps.stylesheets(),
nodeProps.userAgentStylesheet()
));
}
treeRoot.getChildren().add(window);
}
treeView.setRoot(treeRoot);
treeView.setShowRoot(true);
}
///////////////////////////////////////////////////////////////////////////
private void createLayout() {
treeView.setContextMenu(createContextMenu());
treeView.setCellFactory(param ->
new StylesheetTreeCell(
this::getOrCreateTextViewDialog,
this::getSourceCode
)
);
VBox.setVgrow(treeView, Priority.ALWAYS);
var hintIcon = new StackPane();
hintIcon.getStyleClass().add("icon");
var hintLabel = new Label("user agent stylesheet", hintIcon);
hintLabel.getStyleClass().add("hint");
setId("stylesheet-tab");
getStyleClass().setAll("tab");
getChildren().setAll(treeView, hintLabel);
}
private ContextMenu createContextMenu() {
var refresh = new MenuItem("Refresh");
refresh.setOnAction(e -> update());
var contextMenu = new ContextMenu();
contextMenu.setId(ConnectorOptions.AUX_NODE_ID_PREFIX + "stylesheetOptionsMenu");
contextMenu.getItems().addAll(refresh);
return contextMenu;
}
private StylesheetTreeItem createTreeItem(int uid,
String name,
List<String> stylesheets,
@Nullable String userAgentStyleSheet) {
var parent = StylesheetTreeItem.of(uid, name);
parent.setExpanded(true);
if (userAgentStyleSheet != null) {
parent.getChildren().add(StylesheetTreeItem.of(uid, userAgentStyleSheet, true));
}
for (String uri : stylesheets) {
var child = StylesheetTreeItem.of(uid, uri, false);
parent.getChildren().add(child);
}
return parent;
}
private String getPlatformUserAgentStylesheet() {
return toolPane.getConnector().getUserAgentStylesheet();
}
private @Nullable String getSourceCode(int uid, Stylesheet stylesheet) {
if (stylesheet.isDataURI()) {
return stylesheet.decodeFromDataURI();
}
return toolPane.getConnector().getResource(uid, stylesheet.uri());
}
private Dialog<TextView> getOrCreateTextViewDialog() {
if (textViewDialog == null) {
textViewDialog = new Dialog<>(new TextView(), "Source Code", 640, 480);
}
return textViewDialog;
}
}

View File

@ -0,0 +1,86 @@
package devtoolsfx.gui.style;
import devtoolsfx.gui.controls.Dialog;
import devtoolsfx.gui.controls.TextView;
import devtoolsfx.gui.util.Formatters;
import javafx.css.PseudoClass;
import javafx.scene.control.TreeCell;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.StackPane;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Supplier;
@NullMarked
final class StylesheetTreeCell extends TreeCell<String> {
private static final PseudoClass UA_STYLESHEET = PseudoClass.getPseudoClass("user-agent");
private static final int MAX_NUMBER_OF_LINES = 3;
private final StackPane icon = new StackPane();
public StylesheetTreeCell(Supplier<Dialog<TextView>> textViewDialog,
BiFunction<Integer, Stylesheet, @Nullable String> sourceCodeProvider) {
super();
icon.getStyleClass().add("icon");
setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.PRIMARY
&& e.getClickCount() == 2
&& getTreeItem() instanceof StylesheetTreeItem item
&& item.isLeaf()
&& item.getStylesheet() != null) {
Dialog<TextView> dialog = textViewDialog.get();
dialog.getRoot().setText(Objects.requireNonNullElse(
sourceCodeProvider.apply(item.getUid(), item.getStylesheet()),
"Unable to obtain the source code."
));
dialog.show();
dialog.toFront();
}
});
}
@Override
protected void updateItem(String value, boolean empty) {
super.updateItem(value, empty);
if (empty) {
setText(null);
setGraphic(null);
pseudoClassStateChanged(UA_STYLESHEET, false);
return;
}
String text = value;
boolean isUserAgentStylesheet = false;
if (getTreeItem() instanceof StylesheetTreeItem item && item.getStylesheet() != null) {
Stylesheet stylesheet = item.getStylesheet();
text = stylesheet.uri();
if (item.getUid() == StylesheetTab.ROOT_UID) {
text = StylesheetTab.ROOT_ITEM_NAME + " [" + text + "]";
}
if (stylesheet.isDataURI()) {
text = Formatters.limitNumberOfLines(stylesheet.decodeFromDataURI(), MAX_NUMBER_OF_LINES, "\n...");
}
if (stylesheet.isUserAgentStylesheet()) {
isUserAgentStylesheet = true;
setGraphic(icon);
} else {
setGraphic(null);
}
}
setText(text);
pseudoClassStateChanged(UA_STYLESHEET, isUserAgentStylesheet);
}
}

View File

@ -0,0 +1,34 @@
package devtoolsfx.gui.style;
import javafx.scene.control.TreeItem;
import org.jspecify.annotations.Nullable;
final class StylesheetTreeItem extends TreeItem<String> {
private final int uid;
private final @Nullable Stylesheet stylesheet;
public StylesheetTreeItem(int uid, String value, @Nullable Stylesheet stylesheet) {
super(value);
this.uid = uid;
this.stylesheet = stylesheet;
}
int getUid() {
return uid;
}
@Nullable Stylesheet getStylesheet() {
return stylesheet;
}
///////////////////////////////////////////////////////////////////////////
public static StylesheetTreeItem of(int uid, String name) {
return new StylesheetTreeItem(uid, name, null);
}
public static StylesheetTreeItem of(int uid, String uri, boolean isUserAgentStylesheet) {
return new StylesheetTreeItem(uid, uri, new Stylesheet(uri, isUserAgentStylesheet));
}
}

Some files were not shown because too many files have changed in this diff Show More