Initial
This commit is contained in:
commit
e653e8fda1
218
.gitignore
vendored
Executable file
218
.gitignore
vendored
Executable 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
|
BIN
.screenshots/environment.png
Normal file
BIN
.screenshots/environment.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 133 KiB |
BIN
.screenshots/events.png
Normal file
BIN
.screenshots/events.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 351 KiB |
BIN
.screenshots/inspector.png
Normal file
BIN
.screenshots/inspector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 166 KiB |
BIN
.screenshots/stylesheets.png
Normal file
BIN
.screenshots/stylesheets.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
21
LICENSE
Executable file
21
LICENSE
Executable 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
45
README.md
Normal 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
42
connector/pom.xml
Normal 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>
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
287
connector/src/main/java/devtoolsfx/connector/BoundsPane.java
Normal file
287
connector/src/main/java/devtoolsfx/connector/BoundsPane.java
Normal 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);
|
||||
}
|
||||
}
|
121
connector/src/main/java/devtoolsfx/connector/Connector.java
Normal file
121
connector/src/main/java/devtoolsfx/connector/Connector.java
Normal 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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
35
connector/src/main/java/devtoolsfx/connector/Env.java
Normal file
35
connector/src/main/java/devtoolsfx/connector/Env.java
Normal 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();
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
122
connector/src/main/java/devtoolsfx/connector/InspectPane.java
Normal file
122
connector/src/main/java/devtoolsfx/connector/InspectPane.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
41
connector/src/main/java/devtoolsfx/connector/KeyValue.java
Normal file
41
connector/src/main/java/devtoolsfx/connector/KeyValue.java
Normal 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);
|
||||
}
|
||||
}
|
339
connector/src/main/java/devtoolsfx/connector/LocalConnector.java
Normal file
339
connector/src/main/java/devtoolsfx/connector/LocalConnector.java
Normal 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();
|
||||
}
|
||||
}
|
243
connector/src/main/java/devtoolsfx/connector/LocalElement.java
Normal file
243
connector/src/main/java/devtoolsfx/connector/LocalElement.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
99
connector/src/main/java/devtoolsfx/connector/LocalEnv.java
Normal file
99
connector/src/main/java/devtoolsfx/connector/LocalEnv.java
Normal 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();
|
||||
}
|
||||
}
|
596
connector/src/main/java/devtoolsfx/connector/WindowMonitor.java
Normal file
596
connector/src/main/java/devtoolsfx/connector/WindowMonitor.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* The package contains the API for monitoring target node scene-graph changes.
|
||||
*/
|
||||
|
||||
package devtoolsfx.connector;
|
@ -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("; "))
|
||||
+ "]";
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
28
connector/src/main/java/devtoolsfx/event/ConnectorEvent.java
Normal file
28
connector/src/main/java/devtoolsfx/event/ConnectorEvent.java
Normal 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();
|
||||
}
|
11
connector/src/main/java/devtoolsfx/event/ElementEvent.java
Normal file
11
connector/src/main/java/devtoolsfx/event/ElementEvent.java
Normal 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();
|
||||
}
|
81
connector/src/main/java/devtoolsfx/event/EventBus.java
Normal file
81
connector/src/main/java/devtoolsfx/event/EventBus.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
18
connector/src/main/java/devtoolsfx/event/EventSource.java
Normal file
18
connector/src/main/java/devtoolsfx/event/EventSource.java
Normal 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;
|
||||
}
|
||||
}
|
44
connector/src/main/java/devtoolsfx/event/ExceptionEvent.java
Normal file
44
connector/src/main/java/devtoolsfx/event/ExceptionEvent.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
33
connector/src/main/java/devtoolsfx/event/JavaFXEvent.java
Normal file
33
connector/src/main/java/devtoolsfx/event/JavaFXEvent.java
Normal 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;
|
||||
}
|
||||
}
|
43
connector/src/main/java/devtoolsfx/event/MousePosEvent.java
Normal file
43
connector/src/main/java/devtoolsfx/event/MousePosEvent.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
31
connector/src/main/java/devtoolsfx/event/NodeAddedEvent.java
Normal file
31
connector/src/main/java/devtoolsfx/event/NodeAddedEvent.java
Normal 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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
}
|
87
connector/src/main/java/devtoolsfx/scenegraph/Element.java
Normal file
87
connector/src/main/java/devtoolsfx/scenegraph/Element.java
Normal 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();
|
||||
}
|
||||
}
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
29
connector/src/main/java/devtoolsfx/scenegraph/Vertex.java
Normal file
29
connector/src/main/java/devtoolsfx/scenegraph/Vertex.java
Normal 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();
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
@ -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) {
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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() + "'");
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
38
connector/src/main/java/devtoolsfx/util/ClassInfoCache.java
Normal file
38
connector/src/main/java/devtoolsfx/util/ClassInfoCache.java
Normal 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;
|
||||
}
|
||||
}
|
377
connector/src/main/java/devtoolsfx/util/SceneUtils.java
Normal file
377
connector/src/main/java/devtoolsfx/util/SceneUtils.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
11
connector/src/main/java/module-info.java
Executable file
11
connector/src/main/java/module-info.java
Executable 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
46
demo/pom.xml
Normal 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>
|
83
demo/src/main/java/devtoolsfx/Launcher.java
Executable file
83
demo/src/main/java/devtoolsfx/Launcher.java
Executable 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);
|
||||
}
|
||||
}
|
8
demo/src/main/java/module-info.java
Normal file
8
demo/src/main/java/module-info.java
Normal file
@ -0,0 +1,8 @@
|
||||
module devtoolsfx.demo {
|
||||
|
||||
requires javafx.controls;
|
||||
requires devtoolsfx.gui;
|
||||
requires fr.brouillard.oss.cssfx;
|
||||
|
||||
exports devtoolsfx;
|
||||
}
|
3
demo/src/main/resources/demo.css
Normal file
3
demo/src/main/resources/demo.css
Normal file
@ -0,0 +1,3 @@
|
||||
.foo {
|
||||
-fx-padding: 10;
|
||||
}
|
22
gui/pom.xml
Normal file
22
gui/pom.xml
Normal 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>
|
108
gui/src/main/java/devtoolsfx/gui/GUI.java
Normal file
108
gui/src/main/java/devtoolsfx/gui/GUI.java
Normal 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));
|
||||
}
|
||||
}
|
235
gui/src/main/java/devtoolsfx/gui/Preferences.java
Normal file
235
gui/src/main/java/devtoolsfx/gui/Preferences.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
380
gui/src/main/java/devtoolsfx/gui/ToolPane.java
Normal file
380
gui/src/main/java/devtoolsfx/gui/ToolPane.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
49
gui/src/main/java/devtoolsfx/gui/controls/Dialog.java
Normal file
49
gui/src/main/java/devtoolsfx/gui/controls/Dialog.java
Normal 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);
|
||||
}
|
||||
}
|
76
gui/src/main/java/devtoolsfx/gui/controls/FilterField.java
Normal file
76
gui/src/main/java/devtoolsfx/gui/controls/FilterField.java
Normal 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());
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
69
gui/src/main/java/devtoolsfx/gui/controls/TabLine.java
Normal file
69
gui/src/main/java/devtoolsfx/gui/controls/TabLine.java
Normal 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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
37
gui/src/main/java/devtoolsfx/gui/controls/TextView.java
Normal file
37
gui/src/main/java/devtoolsfx/gui/controls/TextView.java
Normal 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");
|
||||
}
|
||||
}
|
241
gui/src/main/java/devtoolsfx/gui/env/EnvironmentTab.java
vendored
Normal file
241
gui/src/main/java/devtoolsfx/gui/env/EnvironmentTab.java
vendored
Normal 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()));
|
||||
}
|
||||
}
|
239
gui/src/main/java/devtoolsfx/gui/eventlog/EventLogTab.java
Normal file
239
gui/src/main/java/devtoolsfx/gui/eventlog/EventLogTab.java
Normal 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;
|
||||
}
|
||||
}
|
89
gui/src/main/java/devtoolsfx/gui/eventlog/Log.java
Normal file
89
gui/src/main/java/devtoolsfx/gui/eventlog/Log.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
118
gui/src/main/java/devtoolsfx/gui/inspector/AttributePane.java
Normal file
118
gui/src/main/java/devtoolsfx/gui/inspector/AttributePane.java
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
134
gui/src/main/java/devtoolsfx/gui/inspector/InspectorTab.java
Normal file
134
gui/src/main/java/devtoolsfx/gui/inspector/InspectorTab.java
Normal 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);
|
||||
}
|
||||
}
|
120
gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphPane.java
Normal file
120
gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphPane.java
Normal 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();
|
||||
}
|
||||
}
|
528
gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTree.java
Normal file
528
gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTree.java
Normal 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()));
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
160
gui/src/main/java/devtoolsfx/gui/inspector/SearchField.java
Normal file
160
gui/src/main/java/devtoolsfx/gui/inspector/SearchField.java
Normal 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();
|
||||
}
|
||||
}
|
154
gui/src/main/java/devtoolsfx/gui/preferences/PreferencesTab.java
Normal file
154
gui/src/main/java/devtoolsfx/gui/preferences/PreferencesTab.java
Normal 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;
|
||||
}
|
||||
}
|
24
gui/src/main/java/devtoolsfx/gui/style/Stylesheet.java
Normal file
24
gui/src/main/java/devtoolsfx/gui/style/Stylesheet.java
Normal 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);
|
||||
}
|
||||
}
|
161
gui/src/main/java/devtoolsfx/gui/style/StylesheetTab.java
Normal file
161
gui/src/main/java/devtoolsfx/gui/style/StylesheetTab.java
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
Loading…
x
Reference in New Issue
Block a user