commit e653e8fda1157a7c513723162dfaf5c25d5a1246 Author: mkpaz Date: Thu Oct 10 16:22:56 2024 +0400 Initial diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..c1b16ba --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.screenshots/environment.png b/.screenshots/environment.png new file mode 100644 index 0000000..5a719a8 Binary files /dev/null and b/.screenshots/environment.png differ diff --git a/.screenshots/events.png b/.screenshots/events.png new file mode 100644 index 0000000..fbfa9c6 Binary files /dev/null and b/.screenshots/events.png differ diff --git a/.screenshots/inspector.png b/.screenshots/inspector.png new file mode 100644 index 0000000..b0eb78d Binary files /dev/null and b/.screenshots/inspector.png differ diff --git a/.screenshots/stylesheets.png b/.screenshots/stylesheets.png new file mode 100644 index 0000000..66c3064 Binary files /dev/null and b/.screenshots/stylesheets.png differ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..994d1fc --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9df20e7 --- /dev/null +++ b/README.md @@ -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. + +

+inspector +

+ +Find more screenshots [here](https://github.com/mkpaz/devtoolsfx/tree/master/.screenshots). + +## Getting started + +Maven: + +```xml + + + io.github.mkpaz + devtoolsfx-gui + TBD + +``` + +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. diff --git a/connector/pom.xml b/connector/pom.xml new file mode 100644 index 0000000..eb4cc68 --- /dev/null +++ b/connector/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + io.github.mkpaz + devtoolsfx + 1.0-SNAPSHOT + + + devtoolsfx-connector + + + + org.openjfx + javafx-controls + + + org.jspecify + jspecify + + + + org.junit.jupiter + junit-jupiter-api + + + org.junit.jupiter + junit-jupiter-engine + + + org.junit.jupiter + junit-jupiter-params + + + org.assertj + assertj-core + + + + diff --git a/connector/src/main/java/devtoolsfx/connector/AttributeListener.java b/connector/src/main/java/devtoolsfx/connector/AttributeListener.java new file mode 100644 index 0000000..8880ecb --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/AttributeListener.java @@ -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 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(); + } + } + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/BoundsPane.java b/connector/src/main/java/devtoolsfx/connector/BoundsPane.java new file mode 100644 index 0000000..0a94883 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/BoundsPane.java @@ -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); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/Connector.java b/connector/src/main/java/devtoolsfx/connector/Connector.java new file mode 100644 index 0000000..7ba81c0 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/Connector.java @@ -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 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> 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); +} diff --git a/connector/src/main/java/devtoolsfx/connector/ConnectorOptions.java b/connector/src/main/java/devtoolsfx/connector/ConnectorOptions.java new file mode 100644 index 0000000..8ee79a9 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/ConnectorOptions.java @@ -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); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/Env.java b/connector/src/main/java/devtoolsfx/connector/Env.java new file mode 100644 index 0000000..04d055d --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/Env.java @@ -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 getSystemProperties(); + + /** + * Returns the list of env variables for the JavaFX JVM process. + */ + List getEnvVariables(); + + /** + * Returns the list of conditional features for the monitored JavaFX application. + */ + List getConditionalFeatures(); + + /** + * Returns the list of platform preferences for the monitored JavaFX application. + */ + List getPlatformPreferences(); + + /** + * Returns the list of optional platform preferences for the monitored JavaFX application. + */ + List getOtherPlatformProperties(); +} diff --git a/connector/src/main/java/devtoolsfx/connector/HighlightOptions.java b/connector/src/main/java/devtoolsfx/connector/HighlightOptions.java new file mode 100644 index 0000000..f1c091f --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/HighlightOptions.java @@ -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); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/InspectPane.java b/connector/src/main/java/devtoolsfx/connector/InspectPane.java new file mode 100644 index 0000000..9b72dc8 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/InspectPane.java @@ -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 + } + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/KeyValue.java b/connector/src/main/java/devtoolsfx/connector/KeyValue.java new file mode 100644 index 0000000..4c3836c --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/KeyValue.java @@ -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 { + + public static final Comparator 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); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/LocalConnector.java b/connector/src/main/java/devtoolsfx/connector/LocalConnector.java new file mode 100644 index 0000000..466cf51 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/LocalConnector.java @@ -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 monitors = new HashMap<>(); + + private final ListChangeListener 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 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> 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 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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/LocalElement.java b/connector/src/main/java/devtoolsfx/connector/LocalElement.java new file mode 100644 index 0000000..78735eb --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/LocalElement.java @@ -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 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 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 getChildren() { + return root != null ? List.of(root) : List.of(); + } + + @Override + public boolean hasChildren() { + return root != null; + } + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/LocalEnv.java b/connector/src/main/java/devtoolsfx/connector/LocalEnv.java new file mode 100644 index 0000000..b5a3e43 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/LocalEnv.java @@ -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 getSystemProperties() { + return System.getProperties().entrySet().stream() + .map(KeyValue::of) + .toList(); + } + + @Override + public List getEnvVariables() { + return System.getenv().entrySet().stream() + .map(KeyValue::of) + .toList(); + } + + @Override + public List getConditionalFeatures() { + return Arrays.stream(ConditionalFeature.values()) + .map(cf -> new KeyValue( + "ConditionalFeature." + cf.toString(), + String.valueOf(Platform.isSupported(cf))) + ) + .toList(); + } + + @Override + public List 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 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 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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java b/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java new file mode 100644 index 0000000..ee3cd95 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/WindowMonitor.java @@ -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 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> getStyledElements() { + List 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 sceneRootChangeListener = (obs, old, val) -> changeRoot(old, val); + + /** + * Called when the window's Scene value changes. + */ + private final ChangeListener 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 mouseMoveHighlightFilter = this::onMouseHover; + + private void onMouseHover(MouseEvent event) { + highlightHoveredNode(event); + } + + /** + * Handles mouse move events to select a clicked node. + */ + private final EventHandler 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 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 nodeChildrenListener = this::onNodeChildrenChanged; + + private void onNodeChildrenChanged(ListChangeListener.Change 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 nodeVisibilityChangeListener = this::onNodeVisibilityChanged; + + @SuppressWarnings("unchecked") + private void onNodeVisibilityChanged(Observable obs, Boolean wasVisible, Boolean nowVisible) { + var node = (Node) ((Property) 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 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 children = SceneUtils.getChildren(node); + children.removeListener(nodeChildrenListener); + children.addListener(nodeChildrenListener); + + for (var child : children) { + addNodeBranchListeners(child); + } + } + + /** + * The opposite of {@link #addNodeBranchListeners(Node)}. + *

+ * 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 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 void fire(T event) { + if (started) { + eventBus.fire(event); + } + } +} diff --git a/connector/src/main/java/devtoolsfx/connector/package-info.java b/connector/src/main/java/devtoolsfx/connector/package-info.java new file mode 100644 index 0000000..09c1607 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/connector/package-info.java @@ -0,0 +1,5 @@ +/** + * The package contains the API for monitoring target node scene-graph changes. + */ + +package devtoolsfx.connector; diff --git a/connector/src/main/java/devtoolsfx/event/AttributeListEvent.java b/connector/src/main/java/devtoolsfx/event/AttributeListEvent.java new file mode 100644 index 0000000..b0d700e --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/AttributeListEvent.java @@ -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> 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("; ")) + + "]"; + } +} diff --git a/connector/src/main/java/devtoolsfx/event/AttributeUpdatedEvent.java b/connector/src/main/java/devtoolsfx/event/AttributeUpdatedEvent.java new file mode 100644 index 0000000..20879af --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/AttributeUpdatedEvent.java @@ -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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/ConnectorEvent.java b/connector/src/main/java/devtoolsfx/event/ConnectorEvent.java new file mode 100644 index 0000000..71ba9f0 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/ConnectorEvent.java @@ -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(); +} diff --git a/connector/src/main/java/devtoolsfx/event/ElementEvent.java b/connector/src/main/java/devtoolsfx/event/ElementEvent.java new file mode 100644 index 0000000..7531187 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/ElementEvent.java @@ -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(); +} diff --git a/connector/src/main/java/devtoolsfx/event/EventBus.java b/connector/src/main/java/devtoolsfx/event/EventBus.java new file mode 100644 index 0000000..8bb3a1d --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/EventBus.java @@ -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, Set>> 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 void subscribe(Class eventType, Consumer subscriber) { + Set> eventSubscribers = getOrCreateSubscribers(eventType); + eventSubscribers.add(subscriber); + } + + /** + * Unsubscribe from all event types. + */ + public void unsubscribe(Consumer 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 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) subscriber)); + } + + /////////////////////////////////////////////////////////////////////////// + + private Set> getOrCreateSubscribers(Class eventType) { + return subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArraySet<>()); + } + + private void fire(E event, Consumer 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()); + } + } +} diff --git a/connector/src/main/java/devtoolsfx/event/EventSource.java b/connector/src/main/java/devtoolsfx/event/EventSource.java new file mode 100644 index 0000000..2f6c022 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/EventSource.java @@ -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; + } +} diff --git a/connector/src/main/java/devtoolsfx/event/ExceptionEvent.java b/connector/src/main/java/devtoolsfx/event/ExceptionEvent.java new file mode 100644 index 0000000..0cc4f8a --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/ExceptionEvent.java @@ -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() + ); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/JavaFXEvent.java b/connector/src/main/java/devtoolsfx/event/JavaFXEvent.java new file mode 100644 index 0000000..c1add39 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/JavaFXEvent.java @@ -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 eventType, + String value) implements ConnectorEvent, ElementEvent { + + @Override + public Element getElement() { + return element; + } + + @Override + public String toLogString() { + return "source=" + eventSource.toLogString() + + " | type=" + eventType + + " | value=" + value; + } +} diff --git a/connector/src/main/java/devtoolsfx/event/MousePosEvent.java b/connector/src/main/java/devtoolsfx/event/MousePosEvent.java new file mode 100644 index 0000000..2bfdc54 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/MousePosEvent.java @@ -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() + ); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/NodeAddedEvent.java b/connector/src/main/java/devtoolsfx/event/NodeAddedEvent.java new file mode 100644 index 0000000..be1b401 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/NodeAddedEvent.java @@ -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); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/NodeRemovedEvent.java b/connector/src/main/java/devtoolsfx/event/NodeRemovedEvent.java new file mode 100644 index 0000000..3933ffd --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/NodeRemovedEvent.java @@ -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()); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/NodeSelectedEvent.java b/connector/src/main/java/devtoolsfx/event/NodeSelectedEvent.java new file mode 100644 index 0000000..bb01fe1 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/NodeSelectedEvent.java @@ -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()); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/NodeStyleClassEvent.java b/connector/src/main/java/devtoolsfx/event/NodeStyleClassEvent.java new file mode 100644 index 0000000..7de5019 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/NodeStyleClassEvent.java @@ -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 styleClass) implements ConnectorEvent { + @Override + public String toLogString() { + return "source=" + eventSource.toLogString() + + " | class=" + element.getSimpleClassName() + + " | styleClass=" + styleClass; + } +} diff --git a/connector/src/main/java/devtoolsfx/event/NodeVisibilityEvent.java b/connector/src/main/java/devtoolsfx/event/NodeVisibilityEvent.java new file mode 100644 index 0000000..0ffbe5c --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/NodeVisibilityEvent.java @@ -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; + } +} diff --git a/connector/src/main/java/devtoolsfx/event/RootChangedEvent.java b/connector/src/main/java/devtoolsfx/event/RootChangedEvent.java new file mode 100644 index 0000000..cc49cbe --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/RootChangedEvent.java @@ -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()); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/WindowClosedEvent.java b/connector/src/main/java/devtoolsfx/event/WindowClosedEvent.java new file mode 100644 index 0000000..3574d58 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/WindowClosedEvent.java @@ -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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/event/WindowPropertiesEvent.java b/connector/src/main/java/devtoolsfx/event/WindowPropertiesEvent.java new file mode 100644 index 0000000..12b79c7 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/event/WindowPropertiesEvent.java @@ -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() + ); + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/ClassInfo.java b/connector/src/main/java/devtoolsfx/scenegraph/ClassInfo.java new file mode 100644 index 0000000..9ea1637 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/ClassInfo.java @@ -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) { +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/Element.java b/connector/src/main/java/devtoolsfx/scenegraph/Element.java new file mode 100644 index 0000000..a1d22ba --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/Element.java @@ -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}. + *

+ * 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 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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/NodeProperties.java b/connector/src/main/java/devtoolsfx/scenegraph/NodeProperties.java new file mode 100644 index 0000000..e99936c --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/NodeProperties.java @@ -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 styleClass, + List 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() + ); + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/Vertex.java b/connector/src/main/java/devtoolsfx/scenegraph/Vertex.java new file mode 100644 index 0000000..7715a2d --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/Vertex.java @@ -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 getChildren(); + + /** + * Returns whether the vertex has any child elements. + */ + boolean hasChildren(); +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/WindowProperties.java b/connector/src/main/java/devtoolsfx/scenegraph/WindowProperties.java new file mode 100644 index 0000000..aa460fc --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/WindowProperties.java @@ -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 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 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); + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/Attribute.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Attribute.java new file mode 100644 index 0000000..a8d3617 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Attribute.java @@ -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( + String name, + @Nullable V value, + @Nullable String field, + @Nullable String cssProperty, + ObservableType observableType, + DisplayHint displayHint, + ValueState valueState, + List 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 > 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. + *

  • DEFAULT - indicates that the attribute value is in its default state
  • + *
  • CHANGED - indicates that the attribute value has been modified
  • + *
  • AUTO - indicates that the attribute value is auto-computed or constant
  • + */ + public enum ValueState { + DEFAULT, + CHANGED, + AUTO; + + public static ValueState defaultIf(boolean state) { + return state ? DEFAULT : CHANGED; + } + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/AttributeCategory.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/AttributeCategory.java new file mode 100644 index 0000000..81721ca --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/AttributeCategory.java @@ -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); + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/Clip.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Clip.java new file mode 100644 index 0000000..87810fb --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Clip.java @@ -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) { +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/ControlTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ControlTracker.java new file mode 100644 index 0000000..17def13 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ControlTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/GridPaneTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/GridPaneTracker.java new file mode 100644 index 0000000..78fc196 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/GridPaneTracker.java @@ -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 SUPPORTED_PROPERTIES = List.of( + "hgap", "vgap", "alignment", "gridLinesVisible", "rowConstrains", "columnConstraints" + ); + + private final ListChangeListener rowListener = c -> reload("rowConstrains"); + private final ListChangeListener 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/ImageViewTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ImageViewTracker.java new file mode 100644 index 0000000..a0f3818 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ImageViewTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/LabeledTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/LabeledTracker.java new file mode 100644 index 0000000..d462e84 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/LabeledTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/NodeTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/NodeTracker.java new file mode 100644 index 0000000..c79322c --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/NodeTracker.java @@ -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 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 SUB_SCENE_PROPERTIES = List.of("userAgentStylesheet"); + + private final ListChangeListener 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 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 getLayoutConstraints(Node node) { + if (!node.hasProperties()) { + return Map.of(); + } + + Map 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); + } + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/ParentTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ParentTracker.java new file mode 100644 index 0000000..ebf8e29 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ParentTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/PropertyListener.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/PropertyListener.java new file mode 100644 index 0000000..2900ee5 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/PropertyListener.java @@ -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. + *

    + * 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, 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, 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(); + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/ReflectiveTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ReflectiveTracker.java new file mode 100644 index 0000000..e57f43d --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ReflectiveTracker.java @@ -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> orderedProperties = new TreeMap<>(); + private final Map, 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, 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 + ); + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/RegionTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/RegionTracker.java new file mode 100644 index 0000000..aba5725 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/RegionTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/SceneTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/SceneTracker.java new file mode 100644 index 0000000..0c55b94 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/SceneTracker.java @@ -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 NON_REFLECTIVE_PROPERTIES = Set.of("stylesheets", "userData"); + + private final Map> 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 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, 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 + ); + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/ShapeTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ShapeTracker.java new file mode 100644 index 0000000..9f5470f --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/ShapeTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/TextTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/TextTracker.java new file mode 100644 index 0000000..22c9cb9 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/TextTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/Tracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Tracker.java new file mode 100644 index 0000000..d2ec259 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/Tracker.java @@ -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}. + *

    + * It is also designed (though not yet implemented) to include additional logic for changing + * the property values. + *

    + * 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> mapper, + Collection supportedProperties, + String... properties) { + if (properties.length == 0) { // hot path 1 + var attributes = new ArrayList>(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> 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() + "'"); + } + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/WindowTracker.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/WindowTracker.java new file mode 100644 index 0000000..c44c501 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/WindowTracker.java @@ -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 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; + }; + } +} diff --git a/connector/src/main/java/devtoolsfx/scenegraph/attributes/package-info.java b/connector/src/main/java/devtoolsfx/scenegraph/attributes/package-info.java new file mode 100644 index 0000000..6a97050 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/scenegraph/attributes/package-info.java @@ -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; diff --git a/connector/src/main/java/devtoolsfx/util/ClassInfoCache.java b/connector/src/main/java/devtoolsfx/util/ClassInfoCache.java new file mode 100644 index 0000000..4dc9844 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/util/ClassInfoCache.java @@ -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, 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; + } +} diff --git a/connector/src/main/java/devtoolsfx/util/SceneUtils.java b/connector/src/main/java/devtoolsfx/util/SceneUtils.java new file mode 100644 index 0000000..b1b7070 --- /dev/null +++ b/connector/src/main/java/devtoolsfx/util/SceneUtils.java @@ -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 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 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 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 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 void addListener(@Nullable N node, + Function> 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 void removeListener(@Nullable N node, + Function> 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 void addListener(@Nullable N node, + Function> obs, + ChangeListener 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 void removeListener(@Nullable N node, + Function> obs, + ChangeListener 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 void addEventFilter(@Nullable Scene scene, + EventType eventType, + EventHandler 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 void removeEventFilter(@Nullable Scene scene, + EventType eventType, + EventHandler 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 void addEventFilter(@Nullable Parent parent, + EventType eventType, + EventHandler 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 void removeEventFilter(@Nullable Parent parent, + EventType eventType, + EventHandler eventFilter) { + if (parent != null) { + parent.removeEventFilter(eventType, eventFilter); + } else { + LOGGER.log(Level.INFO, "parent is null, this behavior is probably not expected"); + } + } +} diff --git a/connector/src/main/java/module-info.java b/connector/src/main/java/module-info.java new file mode 100755 index 0000000..9192b83 --- /dev/null +++ b/connector/src/main/java/module-info.java @@ -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; +} diff --git a/demo/pom.xml b/demo/pom.xml new file mode 100644 index 0000000..ca4e412 --- /dev/null +++ b/demo/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + io.github.mkpaz + devtoolsfx + 1.0-SNAPSHOT + + + devtoolsfx-demo + + + + io.github.mkpaz + devtoolsfx-gui + ${project.version} + + + fr.brouillard.oss + cssfx + 11.4.0 + + + + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + + default-cli + + ${java.home}/bin/java + devtoolsfx.Launcher + + + + + + + + diff --git a/demo/src/main/java/devtoolsfx/Launcher.java b/demo/src/main/java/devtoolsfx/Launcher.java new file mode 100755 index 0000000..89f52e5 --- /dev/null +++ b/demo/src/main/java/devtoolsfx/Launcher.java @@ -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); + } +} diff --git a/demo/src/main/java/module-info.java b/demo/src/main/java/module-info.java new file mode 100644 index 0000000..ab24472 --- /dev/null +++ b/demo/src/main/java/module-info.java @@ -0,0 +1,8 @@ +module devtoolsfx.demo { + + requires javafx.controls; + requires devtoolsfx.gui; + requires fr.brouillard.oss.cssfx; + + exports devtoolsfx; +} diff --git a/demo/src/main/resources/demo.css b/demo/src/main/resources/demo.css new file mode 100644 index 0000000..aa08bc5 --- /dev/null +++ b/demo/src/main/resources/demo.css @@ -0,0 +1,3 @@ +.foo { + -fx-padding: 10; +} diff --git a/gui/pom.xml b/gui/pom.xml new file mode 100644 index 0000000..1167189 --- /dev/null +++ b/gui/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + io.github.mkpaz + devtoolsfx + 1.0-SNAPSHOT + + + devtoolsfx-gui + + + + io.github.mkpaz + devtoolsfx-connector + ${project.version} + + + + diff --git a/gui/src/main/java/devtoolsfx/gui/GUI.java b/gui/src/main/java/devtoolsfx/gui/GUI.java new file mode 100644 index 0000000..58ce446 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/GUI.java @@ -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. + *

    + * 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)); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/Preferences.java b/gui/src/main/java/devtoolsfx/gui/Preferences.java new file mode 100644 index 0000000..f3ea1fa --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/Preferences.java @@ -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() + ); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/ToolPane.java b/gui/src/main/java/devtoolsfx/gui/ToolPane.java new file mode 100644 index 0000000..cc6ab92 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/ToolPane.java @@ -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 eventQueue = new ArrayDeque<>(); + + private final ChangeListener ignoreMouseTransparentListener; + private final ChangeListener 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 getMonitorIdentifiers() { + return connector.getEventSources().stream().map(EventSource::uid).toList(); + } + + /** + * See {@link Connector#getStyledElements(int)}}. + */ + public Map.@Nullable Entry> 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. + *

    + * 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 + } + } + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/ColorIndicator.java b/gui/src/main/java/devtoolsfx/gui/controls/ColorIndicator.java new file mode 100644 index 0000000..9eacd1b --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/ColorIndicator.java @@ -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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/Dialog.java b/gui/src/main/java/devtoolsfx/gui/controls/Dialog.java new file mode 100644 index 0000000..22e845d --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/Dialog.java @@ -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

    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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/FilterField.java b/gui/src/main/java/devtoolsfx/gui/controls/FilterField.java new file mode 100644 index 0000000..23edff4 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/FilterField.java @@ -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()); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/FilterableTreeItem.java b/gui/src/main/java/devtoolsfx/gui/controls/FilterableTreeItem.java new file mode 100644 index 0000000..29f5f74 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/FilterableTreeItem.java @@ -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 extends TreeItem { + + private final ObservableList> sourceList = FXCollections.observableArrayList(); + private final FilteredList> filteredList = new FilteredList<>(sourceList); + + public FilterableTreeItem() { + this(null); + } + + public FilterableTreeItem(@Nullable T value) { + super(value); + Bindings.bindContent(getChildren(), filteredList); + } + + public void setItems(List> items) { + sourceList.setAll(items); + } + + public List> getChildrenUnmodifiable() { + return Collections.unmodifiableList(sourceList); + } + + public boolean isEmpty() { + return getChildren().isEmpty(); + } + + public void clear() { + sourceList.clear(); + } + + public void setFilterPredicate(@Nullable Predicate> predicate) { + filteredList.setPredicate(predicate); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/TabLine.java b/gui/src/main/java/devtoolsfx/gui/controls/TabLine.java new file mode 100644 index 0000000..2b42868 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/TabLine.java @@ -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 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 selectionHandler) { + this.selectionHandler = selectionHandler; + } + + /////////////////////////////////////////////////////////////////////////// + + private void createLayout(String... tabs) { + var buttons = new ArrayList(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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/TextInputContextMenu.java b/gui/src/main/java/devtoolsfx/gui/controls/TextInputContextMenu.java new file mode 100644 index 0000000..4412421 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/TextInputContextMenu.java @@ -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 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()); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/controls/TextView.java b/gui/src/main/java/devtoolsfx/gui/controls/TextView.java new file mode 100644 index 0000000..e6665ed --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/controls/TextView.java @@ -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"); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/env/EnvironmentTab.java b/gui/src/main/java/devtoolsfx/gui/env/EnvironmentTab.java new file mode 100644 index 0000000..75f4c88 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/env/EnvironmentTab.java @@ -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 kvTable = new TreeTableView<>(); + private final FilterableTreeItem treeRoot = new FilterableTreeItem<>(); + private final FilterableTreeItem platformRoot = new FilterableTreeItem<>( + new KeyValue("Platform", null) + ); + private final FilterableTreeItem propertiesRoot = new FilterableTreeItem<>( + new KeyValue("System Properties", null) + ); + private final FilterableTreeItem envVariablesRoot = new FilterableTreeItem<>( + new KeyValue("Environment Variables", null) + ); + private @Nullable Dialog 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("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("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 filterable && !filterable.isEmpty() + ); + } + + private > ObservableValue<@Nullable String> columnMapper( + T cdf, Function mapper) { + + if (cdf.getValue() == null || cdf.getValue().getValue() == null) { + return new SimpleStringProperty(null); + } + + return new SimpleStringProperty(mapper.apply(cdf.getValue().getValue())); + } + + private Dialog 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())); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/eventlog/EventLogTab.java b/gui/src/main/java/devtoolsfx/gui/eventlog/EventLogTab.java new file mode 100644 index 0000000..d4d9b8a --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/eventlog/EventLogTab.java @@ -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 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 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 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 getOrCreateTextViewDialog() { + if (textViewDialog == null) { + textViewDialog = new Dialog<>(new TextView(), "Log Entry", 640, 480); + } + + return textViewDialog; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/eventlog/Log.java b/gui/src/main/java/devtoolsfx/gui/eventlog/Log.java new file mode 100644 index 0000000..aee9fb5 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/eventlog/Log.java @@ -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 sourceList = FXCollections.observableList(new LinkedList<>()); + private final FilteredList 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 getEntries() { + return sourceList; + } + + ObservableList 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 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); + } + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/eventlog/OptionsMenuButton.java b/gui/src/main/java/devtoolsfx/gui/eventlog/OptionsMenuButton.java new file mode 100644 index 0000000..0657f3e --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/eventlog/OptionsMenuButton.java @@ -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> PREDISABLED_EVENTS = Set.of( + JavaFXEvent.class, + MousePosEvent.class, + WindowPropertiesEvent.class + ); + + private final CheckMenuItem selectedOnlyItem = new CheckMenuItem("For selected node only"); + private final Map, CheckMenuItem> eventItems = new HashMap<>(); + + OptionsMenuButton(EventHandler 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(); + } + + boolean isEventEnabled(T event) { + var item = eventItems.get(event.getClass()); + return item != null && item.isSelected(); + } + + /////////////////////////////////////////////////////////////////////////// + + private void createMenuItems(EventHandler 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 actionHandler, + Map, 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; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/AttributeCellContent.java b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeCellContent.java new file mode 100644 index 0000000..a9122b2 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeCellContent.java @@ -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 { + + 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; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/AttributeDetailsPane.java b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeDetailsPane.java new file mode 100644 index 0000000..0ad892f --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeDetailsPane.java @@ -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; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/AttributePane.java b/gui/src/main/java/devtoolsfx/gui/inspector/AttributePane.java new file mode 100644 index 0000000..1e90e0a --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/AttributePane.java @@ -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> 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); + } + }); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeItem.java b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeItem.java new file mode 100644 index 0000000..e9b6851 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeItem.java @@ -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 { + + private final ObservableList sourceList = FXCollections.observableArrayList(); + private final FilteredList filteredList = new FilteredList<>(sourceList); + private final SortedList 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> attributes) { + List 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 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 predicate) { + filteredList.setPredicate(predicate); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeTable.java b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeTable.java new file mode 100644 index 0000000..941bec8 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/AttributeTreeTable.java @@ -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 { + + 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> 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("Property"); + var valueCol = new TreeTableColumn("Value"); + + Callback, ObservableValue> + 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); + } + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/ExpandCollapse.java b/gui/src/main/java/devtoolsfx/gui/inspector/ExpandCollapse.java new file mode 100644 index 0000000..7faa424 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/ExpandCollapse.java @@ -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 { + + private final Set expandItems = new HashSet<>(); + private final Set collapseItems = new HashSet<>(); + private final Function, T> fun; + + public ExpandCollapse(Function, 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 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 item) { + toggle(item, true); + } + + /** + * Shortcut for {@link #toggle(TreeItem, boolean)}. + */ + void collapse(TreeItem item) { + toggle(item, false); + } + + /** + * Checks whether the given item is present in the "expanded list". + */ + boolean isExpanded(TreeItem item) { + return expandItems.contains(fun.apply(item)); + } + + /** + * Checks whether the given item is present in the "collapsed list". + */ + boolean isCollapsed(TreeItem item) { + return collapseItems.contains(fun.apply(item)); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/InspectorTab.java b/gui/src/main/java/devtoolsfx/gui/inspector/InspectorTab.java new file mode 100644 index 0000000..9f530dc --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/InspectorTab.java @@ -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 styleClass) { + sceneGraphPane.updateTreeElementStyleClass(element, styleClass); + } + + /** + * Sets (replaces) the list of displayed attributes. + */ + public void setAttributes(AttributeCategory category, List> 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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphPane.java b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphPane.java new file mode 100644 index 0000000..9387c8c --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphPane.java @@ -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> 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 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> 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(); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTree.java b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTree.java new file mode 100644 index 0000000..8d19f13 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTree.java @@ -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 { + + private static final Logger LOGGER = System.getLogger(SceneGraphTree.class.getName()); + + private final ToolPane toolPane; + private final TreeItem treeRoot; + private final Map> treeIndex = new HashMap<>(); + + private final ExpandCollapse forcedNodes = new ExpandCollapse<>( + item -> item.getValue().hashCode() + ); + private final ExpandCollapse 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 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> 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 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 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 item) { + TreeItem 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 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>(element.getChildren().size()); + for (var child : element.getChildren()) { + if (!child.isAuxiliaryElement()) { + children.add(createTreeBranch(child)); + } + } + + for (TreeItem 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}. + * Returns {@code null} if not found. + */ + private @Nullable TreeItem findParentWindowItem(@Nullable TreeItem 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 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 selectedItem = getSelectionModel().getSelectedItem(); + TreeItem itemToAdd = createTreeBranch(elementToAdd); + List siblingElements = parentElement.getChildren(); + List> 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 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> 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 parentItem, Element element) { + if (parentItem == null) { + return false; + } + + for (TreeItem 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 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 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())); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeCell.java b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeCell.java new file mode 100644 index 0000000..7ffc0af --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeCell.java @@ -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 { + + 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(); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeItem.java b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeItem.java new file mode 100644 index 0000000..5a5b54d --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/SceneGraphTreeItem.java @@ -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 implements Comparable> { + + 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 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 getPathIndices(TreeItem item, List accumulator) { + var parent = item.getParent(); + if (parent == null) { + return accumulator; + } + + accumulator.add(parent.getChildren().indexOf(item)); + getPathIndices(parent, accumulator); + + return accumulator; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/inspector/SearchField.java b/gui/src/main/java/devtoolsfx/gui/inspector/SearchField.java new file mode 100644 index 0000000..0307f76 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/inspector/SearchField.java @@ -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 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 navigableResult = List.of(); + private int position = -1; + private @Nullable BiConsumer 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 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 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 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(); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/preferences/PreferencesTab.java b/gui/src/main/java/devtoolsfx/gui/preferences/PreferencesTab.java new file mode 100644 index 0000000..c4c18c9 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/preferences/PreferencesTab.java @@ -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; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/style/Stylesheet.java b/gui/src/main/java/devtoolsfx/gui/style/Stylesheet.java new file mode 100644 index 0000000..f2588da --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/style/Stylesheet.java @@ -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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/style/StylesheetTab.java b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTab.java new file mode 100644 index 0000000..fcf6355 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTab.java @@ -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 treeView = new TreeView<>(); + + private @Nullable Dialog 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> 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 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 getOrCreateTextViewDialog() { + if (textViewDialog == null) { + textViewDialog = new Dialog<>(new TextView(), "Source Code", 640, 480); + } + + return textViewDialog; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeCell.java b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeCell.java new file mode 100644 index 0000000..f8af906 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeCell.java @@ -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 { + + 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> textViewDialog, + BiFunction 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 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); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeItem.java b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeItem.java new file mode 100644 index 0000000..7a82056 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/style/StylesheetTreeItem.java @@ -0,0 +1,34 @@ +package devtoolsfx.gui.style; + +import javafx.scene.control.TreeItem; +import org.jspecify.annotations.Nullable; + +final class StylesheetTreeItem extends TreeItem { + + 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)); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/util/DummyElement.java b/gui/src/main/java/devtoolsfx/gui/util/DummyElement.java new file mode 100644 index 0000000..0945c80 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/util/DummyElement.java @@ -0,0 +1,73 @@ +package devtoolsfx.gui.util; + +import devtoolsfx.scenegraph.ClassInfo; +import devtoolsfx.scenegraph.Element; +import devtoolsfx.scenegraph.NodeProperties; +import devtoolsfx.scenegraph.WindowProperties; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Dummy class to represent the root of a scene graph element tree. + */ +@NullMarked +public final class DummyElement implements Element { + + private final String name; + + public DummyElement(String name) { + this.name = name; + } + + @Override + public int getUID() { + return hashCode(); + } + + @Override + public ClassInfo getClassInfo() { + return new ClassInfo("", "", name); + } + + @Override + public String getSimpleClassName() { + return name; + } + + @Override + public @Nullable Element getParent() { + return null; + } + + @Override + public List getChildren() { + return List.of(); + } + + @Override + public boolean hasChildren() { + return false; + } + + @Override + public @Nullable NodeProperties getNodeProperties() { + return null; + } + + @Override + public @Nullable WindowProperties getWindowProperties() { + return null; + } + + @Override + public boolean isWindowElement() { + return false; + } + + @Override + public boolean isNodeElement() { + return false; + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/util/Formatters.java b/gui/src/main/java/devtoolsfx/gui/util/Formatters.java new file mode 100644 index 0000000..4540de1 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/util/Formatters.java @@ -0,0 +1,179 @@ +package devtoolsfx.gui.util; + +import devtoolsfx.scenegraph.Element; +import devtoolsfx.scenegraph.NodeProperties; +import devtoolsfx.scenegraph.WindowProperties; +import javafx.scene.paint.Color; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.io.PrintWriter; +import java.io.StringWriter; + +@NullMarked +public final class Formatters { + + /** + * Returns the HEX string representation of the specified color. + */ + public 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(); + } + + /** + * Returns the RGBA string representation of the specified color. + */ + public static String colorToRgbString(@Nullable Color color) { + if (color == null) { + return ""; + } + + if (color.getOpacity() == 1) { + return "rgb(%d, %d, %d)".formatted( + (int) (color.getRed() * 255), + (int) (color.getGreen() * 255), + (int) (color.getBlue() * 255) + ); + } + + return "rgba(%d, %d, %d, %s)".formatted( + (int) (color.getRed() * 255), + (int) (color.getGreen() * 255), + (int) (color.getBlue() * 255), + color.getOpacity() % 1.0 != 0 + ? String.format("%s", color.getOpacity()) + : String.format("%.0f", color.getOpacity()) + ); + } + + /** + * Returns the conventional representation of the element for display in the scene graph tree. + */ + public static String formatForTreeItem(Element element) { + var text = element.getSimpleClassName(); + + if (element.isNodeElement()) { + var props = element.getNodeProperties(); + if (props != null) { + text = formatForTreeItem(element.getSimpleClassName(), props); + } + } + + if (element.isWindowElement()) { + var props = element.getWindowProperties(); + if (props != null) { + text = formatForTreeItem(element.getUID(), props); + } + } + + return text; + } + + /** + * See {@link #formatForTreeItem(Element)}. + */ + public static String formatForTreeItem(String className, NodeProperties props) { + var text = className; + + if (props.id() != null) { + text += " " + asPropertyString("id", props.id()); + } + + if (!props.styleClass().isEmpty()) { + text += " " + asPropertyString("class", String.join(" ", props.styleClass())); + } + + return text; + } + + /** + * See {@link #formatForTreeItem(Element)}. + */ + public static String formatForTreeItem(int uid, WindowProperties props) { + String text; + if (props.isPrimaryStage()) { + text = "Primary Stage"; + } else { + text = switch (props.windowType()) { + case STAGE -> "Stage" + ( + props.windowTitle() != null + ? " [" + asPropertyString("title", props.windowTitle()) + "]" + : "@" + uid + ); + case MODAL -> "Modal" + ( + props.windowTitle() != null + ? " [" + asPropertyString("title", props.windowTitle()) + "]" + : "@" + uid + ); + case ALERT -> "Alert" + ( + props.windowTitle() != null + ? " [" + asPropertyString("title", props.windowTitle()) + "]" + : "@" + uid + ); + case POPUP -> "Popup" + ( + props.ownerClassName() != null + ? " [" + asPropertyString("owner", props.ownerClassName()) + : "@" + uid + ); + }; + } + return text; + } + + /** + * Returns a string in the format {@code key="value"}. + */ + public static String asPropertyString(String key, String value) { + return key + "=\"" + value + "\""; + } + + /** + * Trims the specified string to a maximum of {@code count} lines. + * If the string exceeds this limit, it appends the {@code ellipsis} string. + */ + public static String limitNumberOfLines(String s, int count, String ellipsis) { + String[] lines = s.split("\n"); + + if (lines.length <= count) { + return s; + } else { + var sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append(lines[i]); + if (i < count - 1) { + sb.append("\n"); + } + } + sb.append(ellipsis); + + return sb.toString(); + } + } + + /** + * Returns the stack trace of the exception as a string. + */ + public static String exceptionToString(Exception e) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + return sw.toString(); + } +} diff --git a/gui/src/main/java/devtoolsfx/gui/util/GUIHelpers.java b/gui/src/main/java/devtoolsfx/gui/util/GUIHelpers.java new file mode 100644 index 0000000..d787e77 --- /dev/null +++ b/gui/src/main/java/devtoolsfx/gui/util/GUIHelpers.java @@ -0,0 +1,66 @@ +package devtoolsfx.gui.util; + +import javafx.scene.control.ListView; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeTableView; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; + +@NullMarked +public final class GUIHelpers { + + /** + * Sets system clipboard content. + * Null value is ignored. + */ + public static void setClipboard(@Nullable String s) { + if (s == null) { + return; + } + + Clipboard clipboard = Clipboard.getSystemClipboard(); + ClipboardContent content = new ClipboardContent(); + content.putString(s); + clipboard.setContent(content); + } + + /** + * Copies selected items from the TreeTableView to the system clipboard. + */ + public static void copySelectedRowsToClipboard(TreeTableView table, + Function stringConverter) { + if (table.getSelectionModel().isEmpty()) { + return; + } + + var sb = new StringBuilder(); + for (TreeItem item : table.getSelectionModel().getSelectedItems()) { + sb.append(stringConverter.apply(item.getValue())); + sb.append('\n'); + } + + setClipboard(sb.toString()); + } + + /** + * Copies selected items from the ListView to the system clipboard. + */ + public static void copySelectedRowsToClipboard(ListView table, + Function stringConverter) { + if (table.getSelectionModel().isEmpty()) { + return; + } + + var sb = new StringBuilder(); + for (S value : table.getSelectionModel().getSelectedItems()) { + sb.append(stringConverter.apply(value)); + sb.append('\n'); + } + + setClipboard(sb.toString()); + } +} diff --git a/gui/src/main/java/module-info.java b/gui/src/main/java/module-info.java new file mode 100755 index 0000000..877400f --- /dev/null +++ b/gui/src/main/java/module-info.java @@ -0,0 +1,8 @@ +module devtoolsfx.gui { + + requires javafx.controls; + requires devtoolsfx.connector; + requires static org.jspecify; + + exports devtoolsfx.gui; +} diff --git a/gui/src/main/resources/index.css b/gui/src/main/resources/index.css new file mode 100644 index 0000000..4232f45 --- /dev/null +++ b/gui/src/main/resources/index.css @@ -0,0 +1,929 @@ +.root { + /* palette */ + -palette-color-bg: #ffffff; + -palette-color-bg-hover: #f5f5f5; + -palette-color-fg: #24292f; + -palette-color-fg-muted: #818e9e; + -palette-color-border: #d0d7de; + -palette-color-neutral: #edf2fa; + -palette-color-neutral-hover: #d2dff3; + -palette-color-accent: #0969da; + -palette-color-danger: #cf222e; + -palette-color-success: #1a7f37; + -palette-color-warning: #fff8c5; + + /* base */ + -fx-background-color: -palette-color-bg; + -fx-font-size: 13px; + + /* semantic colors */ + -color-cell-bg: -palette-color-bg; + -color-cell-fg: -palette-color-fg; + -color-cell-bg-selected: -palette-color-neutral; + -color-cell-fg-selected: -palette-color-fg; + -color-cell-bg-selected-focused: -palette-color-neutral; + -color-cell-fg-selected-focused: -palette-color-fg; + -color-cell-border: -palette-color-border; + -color-cell-disclosure: -palette-color-fg; + -color-cell-header-bg: -palette-color-neutral; + -color-cell-header-fg: -palette-color-fg; + + -color-split-divider: -palette-color-border; + -color-split-divider-pressed: -palette-color-accent; + -color-split-grabber: -palette-color-border; + -color-split-grabber-pressed: -palette-color-accent; + + -color-input-bg: -palette-color-bg; + -color-input-fg: -palette-color-fg; + -color-input-border: -palette-color-border; + -color-input-bg-focused: -palette-color-bg; + -color-input-border-focused: -palette-color-accent; + -color-input-bg-readonly: -palette-color-bg-hover; + -color-input-bg-highlight: -palette-color-neutral; + -color-input-fg-highlight: -palette-color-fg; + + -color-button-bg: -palette-color-bg; + -color-button-fg: -palette-color-fg; + -color-button-border: -palette-color-border; + -color-button-bg-hover: -palette-color-bg-hover; + -color-button-fg-hover: -palette-color-fg; + -color-button-border-hover: -palette-color-border; + -color-button-bg-focused: -palette-color-bg; + -color-button-fg-focused: -palette-color-fg; + -color-button-border-focused: -palette-color-border; + -color-button-bg-pressed: -palette-color-bg; + -color-button-fg-pressed: -color-button-fg; + -color-button-border-pressed: -palette-color-border; +} + +/****************************************************************************** +* JavaFX Controls / Theme +******************************************************************************/ + +.text { + -fx-font-smoothing-type: lcd; + -fx-bounds-type: logical_vertical_center; +} + +Text { + -fx-fill: -palette-color-fg; +} + +.label { + -fx-text-fill: -palette-color-fg; + -fx-font-weight: normal; +} +.label:disabled { + -fx-opacity: 0.4; +} + +.list-view { + -fx-border-color: -color-cell-border; + -fx-border-width: 1px; + -fx-border-radius: 0; +} +.list-view > .virtual-flow > .corner { + -fx-background-color: -color-cell-border; + -fx-opacity: 0.4; +} +.list-view .list-cell { + -fx-background-color: -color-cell-bg; + -fx-text-fill: -color-cell-fg; + -fx-padding: 4px; + -fx-cell-size: -1; + -fx-border-width: 0; +} + +.tree-view { + -fx-border-color: -color-cell-border; + -fx-border-width: 1px; + -fx-border-radius: 0; +} +.tree-view > .virtual-flow > .corner { + -fx-background-color: -color-cell-border; + -fx-opacity: 0.4; +} +.tree-cell { + -fx-background-color: -color-cell-bg; + -fx-text-fill: -color-cell-fg; + -fx-padding: 4px; + -fx-cell-size: -1; +} +.tree-cell > .tree-disclosure-node { + -fx-padding: 0.35em 0.5em 0 0.5em; + -fx-background-color: transparent; +} +.tree-cell > .tree-disclosure-node > .arrow, +.tree-table-row-cell > .tree-disclosure-node > .arrow { + -fx-shape: "M10 17l5-5-5-5v10z"; + -fx-scale-shape: false; + -fx-background-color: -color-cell-disclosure; + -fx-padding: 0.333333em; +} +.tree-cell:expanded > .tree-disclosure-node > .arrow, +.tree-table-row-cell:expanded > .tree-disclosure-node > .arrow { + -fx-shape: "M7 10l5 5 5-5z"; + -fx-scale-shape: false; +} + +.tree-table-view { + -fx-border-color: -color-cell-border; + -fx-border-width: 1px; + -fx-border-radius: 0; +} +.tree-table-view > .virtual-flow > .corner { + -fx-background-color: -color-cell-border; + -fx-opacity: 0.4; +} +.tree-table-view > .column-header-background { + -fx-background-color: -color-cell-border, -color-cell-header-bg; + -fx-background-insets: 0, 0 0 1 0; +} +.tree-table-view > .column-header-background .column-header { + -fx-size: 2em; + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-padding: 0; + -fx-font-weight: bold; + -fx-border-color: -color-cell-border; + -fx-border-width: 0 1 1 0; +} +.tree-table-view > .column-header-background .column-header .label { + -fx-text-fill: -color-cell-header-fg; + -fx-alignment: CENTER_LEFT; + -fx-padding: 0 4px 0 4px; +} +.tree-table-view > .column-header-background .column-header GridPane { + -fx-padding: 0 4px 0 0; +} +.tree-table-view > .column-header-background .column-header .arrow { + -fx-background-color: -color-cell-header-fg; + -fx-padding: 3px 4px 3px 4px; + -fx-shape: "M 0 0 h 7 l -3.5 4 z"; +} +.tree-table-view > .column-header-background > .filler { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-border-width: 0; +} +.tree-table-view .column-resize-line { + -fx-padding: 0 1 0 1; + -fx-background-color: -palette-color-accent; +} +.tree-table-view .placeholder > .label { + -fx-font-size: 1.25em; +} +.tree-table-view .tree-table-row-cell { + -fx-cell-size: -1; + -fx-background-color: -color-cell-bg; + -fx-background-insets: 0; + -fx-padding: 0; + -fx-indent: 0; +} +.tree-table-view .tree-table-row-cell:empty { + -fx-background-color: transparent; + -fx-background-insets: 0; +} +.tree-table-view .tree-table-row-cell > .tree-disclosure-node { + -fx-padding: 0.5em; + -fx-background-color: transparent; +} +.tree-table-view .tree-table-row-cell > .tree-table-cell { + -fx-padding: 0; + -fx-text-fill: -color-cell-fg; + -fx-alignment: BASELINE_LEFT; +} + +.list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected, +.tree-view > .virtual-flow > .clipped-container > .sheet > .tree-cell:filled:selected, +.tree-table-view > .virtual-flow > .clipped-container > .sheet > .tree-table-row-cell:filled:selected { + -color-cell-fg: -color-cell-fg-selected; + -fx-background-color: -color-cell-bg-selected; +} +.list-view:focused > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected, +.tree-table-view:focused > .virtual-flow > .clipped-container > .sheet > .tree-table-row-cell:filled:selected { + -color-cell-fg: -color-cell-fg-selected-focused; + -fx-background-color: -color-cell-bg-selected-focused; +} +.tree-table-view > .virtual-flow > .clipped-container > .sheet > .tree-table-row-cell .tree-table-cell:selected { + -fx-background-color: -color-cell-bg-selected; +} +.tree-table-view:focused > .virtual-flow > .clipped-container > .sheet > .tree-table-row-cell .tree-table-cell:selected { + -fx-background-color: -color-cell-bg-selected-focused; +} +.tree-table-view:constrained-resize > .virtual-flow > .clipped-container > .sheet > .tree-table-row-cell > .tree-table-cell:last-visible { + -fx-border-color: transparent; +} + +.scroll-bar { + -fx-background-color: -palette-color-bg; + -fx-opacity: 0.75; +} +.scroll-bar > .thumb { + -fx-background-color: -palette-color-border; + -fx-background-radius: 0; +} +.scroll-bar > .track { + -fx-background-color: transparent; + -fx-border-radius: 0; +} +.scroll-bar > .increment-button { + visibility: hidden; + -fx-managed: false; +} +.scroll-bar > .increment-button > .increment-arrow { + -fx-shape: " "; + -fx-padding: 0; +} +.scroll-bar > .decrement-button { + visibility: hidden; + -fx-managed: false; +} +.scroll-bar > .decrement-button > .decrement-arrow { + -fx-shape: " "; + -fx-padding: 0; +} +.scroll-bar:horizontal { + -fx-pref-height: 8px; +} +.scroll-bar:vertical { + -fx-pref-width: 8px; +} +.scroll-bar:hover, .scroll-bar:pressed, .scroll-bar:focused { + -fx-opacity: 1; +} + +.scroll-pane { + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-padding: 0; +} +.scroll-pane > .viewport { + -fx-background-color: transparent; +} +.scroll-pane > .corner { + -fx-background-color: -palette-color-border; + -fx-opacity: 0.5; +} +.scroll-pane:disabled > .scroll-bar { + -fx-opacity: 0.25; +} + +.split-pane { + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-padding: 0; +} +.split-pane > .split-pane-divider { + -fx-background-color: -color-split-divider; + -fx-padding: 0 2px 0 2px; + -fx-opacity: 0.5; +} +.split-pane > .split-pane-divider > .horizontal-grabber { + -fx-background-color: -color-split-grabber; + -fx-padding: 10px 2px 10px 2px; +} +.split-pane > .split-pane-divider > .vertical-grabber { + -fx-background-color: -color-split-grabber; + -fx-padding: 2px 10px 2px 10px; +} +.split-pane > .split-pane-divider:pressed { + -fx-background-color: -color-split-divider-pressed; +} +.split-pane > .split-pane-divider:pressed > .horizontal-grabber, +.split-pane > .split-pane-divider:pressed > .vertical-grabber { + -fx-background-color: -color-split-grabber-pressed; +} +.split-pane > .split-pane-divider:hover { + -fx-opacity: 1; +} +.split-pane > .split-pane-divider:disabled { + -fx-opacity: 0.25; +} + +.check-box { + -fx-text-fill: -palette-color-fg; + -fx-label-padding: 2px 2px 0 6px; +} +.check-box > .box { + -fx-background-color: -palette-color-border, -palette-color-bg; + -fx-background-insets: 0, 1.5px; + -fx-background-radius: 4px, 3px; + -fx-padding: 3px 4px 3px 4px; + -fx-alignment: CENTER; +} +.check-box > .box > .mark { + -fx-background-color: -palette-color-bg; + -fx-shape: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"; + -fx-scale-shape: true; + -fx-min-height: 0.75em; + -fx-max-height: 0.75em; + -fx-min-width: 0.75em; + -fx-max-width: 0.75em; +} +.check-box > .box:hover, +.check-box:selected > .box:hover { + -fx-background-color: -palette-color-border, -palette-color-bg-hover; +} +.check-box:disabled { + -fx-opacity: 0.4; +} +.check-box:disabled > .box { + -fx-opacity: 0.4; +} +.check-box:selected > .box { + -fx-background-color: -palette-color-border, -palette-color-bg; +} +.check-box:selected > .box > .mark { + -fx-background-color: -palette-color-accent; +} + +.text-input { + -fx-background-color: -color-input-border, -color-input-bg; + -fx-background-insets: 0, 1px; + -fx-background-radius: 4px, 3px; + -fx-text-fill: -color-input-fg; + -fx-highlight-fill: -color-input-bg-highlight; + -fx-highlight-text-fill: -color-input-fg-highlight; + -fx-prompt-text-fill: -palette-color-fg-muted; + -fx-padding: 4px 8px 4px 8px; + -fx-cursor: text; +} +.text-input:focused { + -fx-background-color: -color-input-border-focused, -color-input-bg-focused; + -fx-prompt-text-fill: transparent; +} +.text-input:disabled { + -fx-opacity: 0.4; +} +.text-input:disabled > .scroll-pane { + -fx-opacity: 1; +} +.text-input:readonly { + -fx-background-color: -color-input-border, -color-input-bg-readonly; +} +.text-input:readonly:focused { + -fx-background-color: -color-input-border-focused, -color-input-bg-readonly; +} +.text-input .context-menu { + -fx-font-size: 13px; + -fx-font-weight: normal; +} +.text-input .context-menu .menu-item { + -fx-cursor: default; +} + +.text-area { + -fx-padding: 2px; + -fx-cursor: default; +} +.text-area .content { + -fx-cursor: text; + -fx-padding: 8px 12px 8px 12px; +} + +.menu-button { + -fx-background-color: -color-button-border, -color-button-bg; + -fx-background-insets: 0, 1px; + -fx-background-radius: 4px, 3px; + -fx-graphic-text-gap: 6px; + -fx-text-fill: -color-button-fg; + -fx-alignment: CENTER; + -fx-padding: 0; + -fx-alignment: CENTER_LEFT; +} +.menu-button:disabled { + -fx-opacity: 0.4; + -fx-effect: none; +} +.menu-button > .label { + -fx-padding: 4px 8px 4px 8px; + -fx-text-fill: -color-button-fg; +} +.menu-button > .arrow-button { + -fx-padding: 4px 8px 4px 0; +} +.menu-button > .arrow-button > .arrow { + -fx-shape: "M10 17l5-5-5-5v10z"; + -fx-scale-shape: false; + -fx-background-color: -color-button-fg; + -fx-min-width: 0.5em; +} +.menu-button:openvertically > .arrow-button > .arrow { + -fx-shape: "M7 10l5 5 5-5z"; + -fx-scale-shape: false; +} +.menu-button:hover { + -fx-background-color: -color-button-border-hover, -color-button-bg-hover; + -fx-opacity: 0.9; +} +.menu-button:hover > .label { + -fx-text-fill: -color-button-fg-hover; +} +.menu-button:hover > .arrow-button > .arrow { + -fx-background-color: -color-button-fg-hover; +} +.menu-button:focused { + -fx-background-color: -color-button-border-focused, -color-button-bg-focused; +} +.menu-button:focused > .label { + -fx-text-fill: -color-button-fg-focused; +} +.menu-button:focused > .arrow-button > .arrow { + -fx-background-color: -color-button-fg-focused; +} +.menu-button:armed, .menu-button:focused:armed { + -fx-background-color: -color-button-border-pressed, -color-button-bg-pressed; + -fx-text-fill: -color-button-fg-pressed; +} +.menu-button:armed > .label, .menu-button:focused:armed > .label { + -fx-text-fill: -color-button-fg-pressed; +} +.menu-button:armed > .arrow-button > .arrow, .menu-button:focused:armed > .arrow-button > .arrow { + -fx-background-color: -color-button-fg-pressed; +} +.menu-button:disabled > .label { + -fx-opacity: 1; +} + +.context-menu { + -fx-background-color: -palette-color-border, -palette-color-bg; + -fx-background-insets: 0, 1; + -fx-padding: 4px; + -fx-background-radius: 0; + -fx-effect: dropshadow(three-pass-box, -palette-color-border, 6px, 0.3, 0, 2); +} +.context-menu > .scroll-arrow { + -fx-padding: 0.5em; + -fx-background-color: transparent; +} +.context-menu > .scroll-arrow:hover { + -fx-background-color: -palette-color-fg; + -fx-text-fill: -color-fg-default; +} +.context-menu .separator:horizontal { + -fx-padding: 0.25em 0 0.25em 0; +} +.context-menu .separator:horizontal .line { + -fx-border-color: -palette-color-border transparent transparent transparent; + -fx-border-insets: 1px 0.5em 0 0.5em; +} + +.menu { + -fx-background-color: transparent; +} +.menu > .right-container > .arrow { + -fx-shape: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"; + -fx-scale-shape: false; + -fx-background-color: -palette-color-fg; +} +.menu-up-arrow { + -fx-shape: "M7 14l5-5 5 5z"; + -fx-scale-shape: true; + -fx-background-color: -palette-color-fg; + -fx-padding: 3px 4px 3px 4px; +} +.menu-down-arrow { + -fx-shape: "M7 10l5 5 5-5z"; + -fx-scale-shape: true; + -fx-background-color: -palette-color-fg; + -fx-padding: 3px 4px 3px 4px; +} +.menu-item { + -fx-background-color: -palette-color-bg; + -fx-padding: 4px; + -fx-background-radius: 5px; +} +.menu-item > .graphic-container { + -fx-padding: 0 6px 0 0; +} +.menu-item > .label { + -fx-padding: 0 1em 0 0; + -fx-text-fill: -palette-color-fg; +} +.menu-item > .left-container { + -fx-padding: 0 1em 0 0; +} +.menu-item > .right-container { + -fx-padding: 0 0 0 0.5em; +} +.menu-item:focused { + -fx-background-color: -palette-color-bg-hover, -palette-color-bg-hover; +} +.menu-item:focused > .label { + -fx-text-fill: -palette-color-fg; +} +.menu-item:focused > .right-container > .arrow { + -fx-background-color: -palette-color-fg; +} +.menu-item:focused .font-icon, .menu-item:focused .ikonli-font-icon { + -fx-fill: -palette-color-fg; +} +.menu-item:disabled { + -fx-opacity: 0.4; +} +.radio-menu-item:checked > .left-container > .radio, +.check-menu-item:checked > .left-container > .check { + -fx-shape: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"; + -fx-scale-shape: true; + -fx-background-color: -palette-color-fg; + -fx-min-height: 0.75em; + -fx-min-width: 0.75em; + -fx-max-height: 0.75em; + -fx-max-width: 0.75em; +} +.radio-menu-item:hover:checked > .left-container > .radio, +.radio-menu-item:focused:checked > .left-container > .radio, +.check-menu-item:hover:checked > .left-container > .check, +.check-menu-item:focused:checked > .left-container > .check { + -fx-background-color: -palette-color-fg; +} + +.tooltip { + -fx-background-color: -palette-color-border, -palette-color-bg; + -fx-background-insets: 0, 1px; + -fx-background-radius: 4px, 3px; + -fx-text-fill: -palette-color-fg; + -fx-font-size: 13px; + -fx-font-weight: normal; + -fx-padding: 8px 12px 8px 12px; + -fx-opacity: 0.85; + -fx-effect: dropshadow(three-pass-box, -palette-color-border, 6px, 0.3, 0, 2); +} + +.hyperlink { + -fx-text-fill: -palette-color-accent; + -fx-underline: true; + -fx-cursor: hand; +} +.hyperlink:visited { + -fx-text-fill: -palette-color-accent; +} + +/****************************************************************************** +* Layout +******************************************************************************/ + +.tab { + -fx-background-color: -palette-color-bg; +} + +/****************************************************************************** +* Inspector +******************************************************************************/ + +#inspect-button { + -fx-padding: 4px 10px 4px 10px; +} +#inspect-button:hover { + -fx-cursor: hand; +} +#inspect-button .icon { + /* MaterialSymbols.SELECT_ALL */ + -fx-shape: "M294-294v-372h372v372H294Zm72-72h228v-228H366v228ZM216-216v72q-29.7 0-50.85-21.15Q144-186.3 144-216h72Zm-72-78v-72h72v72h-72Zm0-150v-72h72v72h-72Zm0-150v-72h72v72h-72Zm72-150h-72q0-29.7 21.15-50.85Q186.3-816 216-816v72Zm78 600v-72h72v72h-72Zm0-600v-72h72v72h-72Zm150 600v-72h72v72h-72Zm0-600v-72h72v72h-72Zm150 600v-72h72v72h-72Zm0-600v-72h72v72h-72Zm150 600v-72h72q0 30-21.15 51T744-144Zm0-150v-72h72v72h-72Zm0-150v-72h72v72h-72Zm0-150v-72h72v72h-72Zm0-150v-72q29.7 0 50.85 21.15Q816-773.7 816-744h-72Z"; -fx-min-width: 1em; + -fx-pref-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-pref-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; +} +#inspect-button:active .icon, +#inspect-button:hover .icon { + -fx-opacity: 1.0; + -fx-background-color: -palette-color-accent; +} + +#scenegraph-pane > .filter { + -fx-padding: 1px; +} +#scene-graph-tree { + -fx-border-width: 0 0 1 0; +} +#scene-graph-tree .tree-cell:hidden .label { + -fx-text-fill: -palette-color-fg-muted; +} +#scene-graph-tree .tree-cell .label { + -fx-padding: 0; +} +#scene-graph-tree .tree-cell:filtered .label { + -fx-background-color: -palette-color-warning; +} + +#scene-graph-search-field { + -fx-background-color: -palette-color-bg, -palette-color-bg; + -fx-background-insets: 0, 2; + -fx-padding: 2px; + -fx-spacing: 4px; + -fx-background-radius: 5px; +} +#scene-graph-search-field:focus-within { + -fx-background-color: -palette-color-accent, -palette-color-bg; +} +#scene-graph-search-field > .text-field { + -fx-background-color: -palette-color-bg; + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-background-radius: 3px; + -fx-text-fill: -palette-color-fg; + -fx-padding: 2px 6px 2px 6px; +} +#scene-graph-search-field > .controls { + -fx-spacing: 4px; + -fx-padding: 0 4px 0 0; + -fx-background-color: transparent; + -fx-background-radius: 5px; +} +#scene-graph-search-field > .controls > .button { + -fx-background-color: -palette-color-bg; + -fx-padding: 0; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-graphic-text-gap: 0; + -fx-text-fill: -palette-color-fg; + -fx-cursor: hand; +} +#scene-graph-search-field > .controls > .clear-button > .icon { + /* MaterialSymbols.CANCEL */ + -fx-shape: "m339-288 141-141 141 141 51-51-141-141 141-141-51-51-141 141-141-141-51 51 141 141-141 141 51 51ZM480-96q-79 0-149-30t-122.5-82.5Q156-261 126-331T96-480q0-80 30-149.5t82.5-122Q261-804 331-834t149-30q80 0 149.5 30t122 82.5Q804-699 834-629.5T864-480q0 79-30 149t-82.5 122.5Q699-156 629.5-126T480-96Zm0-72q130 0 221-91t91-221q0-130-91-221t-221-91q-130 0-221 91t-91 221q0 130 91 221t221 91Zm0-312Z"; + -fx-min-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; + -fx-opacity: 0.8; +} +#scene-graph-search-field > .controls > .up-button > .icon { + /* MaterialSymbols.KEYBOARD_ARROW_UP */ + -fx-shape: "M480-525 291-336l-51-51 240-240 240 240-51 51-189-189Z"; +} +#scene-graph-search-field > .controls > .down-button > .icon { + /* MaterialSymbols.KEYBOARD_ARROW_DOWN */ + -fx-shape: "M480-333 240-573l51-51 189 189 189-189 51 51-240 240Z"; +} +#scene-graph-search-field > .controls > .arrow-button > .icon { + -fx-min-width: 0.8em; + -fx-max-width: 0.8em; + -fx-min-height: 0.5em; + -fx-max-height: 0.5em; + -fx-background-color: -palette-color-fg; + -fx-opacity: 0.8; +} +#scene-graph-search-field > .controls > .arrow-button:pressed > .icon { + -fx-opacity: 1; +} +#scene-graph-search-field > .controls > .hint { + -fx-font-size: 0.8em; + -fx-text-fill: -palette-color-fg-muted; +} + +#attribute-pane .filter { + -fx-padding: 1px; +} +#attribute-tree-table { + -fx-border-width: 0 1 1 0; +} +#attribute-tree-table .tree-table-row-cell .property-cell { + -fx-font-weight: normal; +} +#attribute-tree-table .tree-table-row-cell .property-cell .info { + -fx-font-size: 0.6em; + -fx-font-weight: normal; + -fx-padding: 0 0 0.5em 0; +} +#attribute-tree-table .tree-table-row-cell > .tree-table-cell { + -fx-alignment: BASELINE_LEFT; +} +#attribute-tree-table .tree-table-row-cell:group .property-cell { + -fx-text-fill: -palette-color-fg; +} +#attribute-tree-table .tree-table-row-cell:default .tree-table-cell { + -fx-text-fill: -palette-color-fg-muted; +} +#attribute-tree-table .tree-table-row-cell:default .property-cell .info { + -fx-text-fill: -palette-color-fg-muted; +} + +#attribute-pane .table-box { + -fx-min-height: 100; +} +#attribute-details-pane { + -fx-min-height: 100; +} +#attribute-details-pane .grid { + -fx-hgap: 20px; + -fx-vgap: 10px; + -fx-padding: 10px 5px 10px 10px; +} +#attribute-details-pane .grid .hyperlink { + -fx-padding: 0; +} +#attribute-details-pane .grid .hyperlink:empty, +#attribute-details-pane .grid .hyperlink:visited:empty { + -fx-text-fill: -palette-color-fg; + -fx-underline: false; +} +#attribute-details-pane .label { + -fx-text-fill: -palette-color-fg; + -fx-font-weight: normal; +} + +/****************************************************************************** +* Event Log +******************************************************************************/ + +#event-log-tab > .log-view { + -fx-font-family: monospaced; +} +#event-log-tab > .controls { + -fx-spacing: 6px; + -fx-padding: 2px 4px 2px 8px; + -fx-alignment: CENTER_LEFT; +} +#event-log-tab > .controls > .button { + -fx-background-color: -palette-color-bg; + -fx-padding: 0 4px 0 0; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-graphic-text-gap: 0; + -fx-text-fill: -palette-color-fg; + -fx-cursor: hand; + -fx-opacity: 0.8; +} +#event-log-tab > .controls > .button:disabled { + -fx-opacity: 0.4; +} +#event-log-tab > .controls > .button:hover { + -fx-opacity: 1.0; +} +#event-log-tab > .controls > .start-stop-button > .icon { + /* MaterialSymbols.RADIO_BUTTON_CHECKED */ + -fx-shape: "M480.23-288Q560-288 616-344.23q56-56.22 56-136Q672-560 615.77-616q-56.22-56-136-56Q400-672 344-615.77q-56 56.22-56 136Q288-400 344.23-344q56.22 56 136 56Zm.05 192Q401-96 331-126t-122.5-82.5Q156-261 126-330.96t-30-149.5Q96-560 126-629.5q30-69.5 82.5-122T330.96-834q69.96-30 149.5-30t149.04 30q69.5 30 122 82.5T834-629.28q30 69.73 30 149Q864-401 834-331t-82.5 122.5Q699-156 629.28-126q-69.73 30-149 30Zm-.28-72q130 0 221-91t91-221q0-130-91-221t-221-91q-130 0-221 91t-91 221q0 130 91 221t221 91Zm0-312Z"; + -fx-min-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; +} +#event-log-tab > .controls > .start-stop-button:started > .icon { + /* MaterialSymbols.STOP_CIRCLE */ + -fx-shape: "M336-336h288v-288H336v288ZM480.28-96Q401-96 331-126t-122.5-82.5Q156-261 126-330.96t-30-149.5Q96-560 126-629.5q30-69.5 82.5-122T330.96-834q69.96-30 149.5-30t149.04 30q69.5 30 122 82.5T834-629.28q30 69.73 30 149Q864-401 834-331t-82.5 122.5Q699-156 629.28-126q-69.73 30-149 30Zm-.28-72q130 0 221-91t91-221q0-130-91-221t-221-91q-130 0-221 91t-91 221q0 130 91 221t221 91Zm0-312Z"; + -fx-background-color: -palette-color-danger; +} +#event-log-tab > .controls > .clear-button > .icon { + /* MaterialSymbols.HIDE_SOURCE */ + -fx-shape: "m768-91-72-72q-48.39 32-103.19 49Q538-97 480.49-97q-79.55 0-149.52-30Q261-157 208.5-209.5T126-331.97q-30-69.97-30-149.52 0-57.51 17-112.32 17-54.8 49-103.19l-72-73 51-51 678 679-51 51Zm-288-78q43.69 0 84.85-12Q606-193 643-216L215-644q-23 37-35 78.15-12 41.16-12 84.85 0 129.67 91.16 220.84Q350.33-169 480-169Zm318-97-53-52q22-37 34.5-78.15Q792-437.31 792-481q0-129.67-91.16-220.84Q609.67-793 480-793q-43 0-84.5 12T317-747l-53-52q48.39-32 103.19-49Q422-865 479.9-865q80.1 0 149.6 30t122 82.5Q804-700 834-630.5t30 149.6q0 57.9-17 112.36T798-266ZM536-531ZM432-427Z"; + -fx-min-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; +} +#event-log-tab > .controls > .export-button > .icon { + /* MaterialSymbols.DOWNLOAD */ + -fx-shape: "M480-336 288-528l51-51 105 105v-342h72v342l105-105 51 51-192 192ZM263.72-192Q234-192 213-213.15T192-264v-72h72v72h432v-72h72v72q0 29.7-21.16 50.85Q725.68-192 695.96-192H263.72Z"; + -fx-min-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; +} +#event-log-tab > .controls > .menu-button { + -fx-background-insets: 0; + -fx-background-radius: 0; +} +#event-log-tab > .status-bar { + -fx-padding: 2px 4px 2px 4px; + -fx-alignment: CENTER_RIGHT +} + +/****************************************************************************** +* Stylesheet +******************************************************************************/ + +#stylesheet-tab .hint { + -fx-padding: 2px 5px 2px 5px; + -fx-font-size: 0.9em; + -fx-graphic-text-gap: 4px; +} +#stylesheet-tab .tree-cell:user-agent .icon, +#stylesheet-tab .hint .icon { + /* MaterialSymbols.FLAG */ + -fx-shape: "M192-144v-672h336l24 96h216v384H528l-24-96H264v288h-72Zm300-431Zm92 167h112v-240H496l-24-96H264v240h296l24 96Z"; -fx-min-width: 0.9em; + -fx-max-width: 0.9em; + -fx-min-height: 0.9em; + -fx-max-height: 0.9em; + -fx-background-color: -palette-color-fg; +} + +/****************************************************************************** +* Environment +******************************************************************************/ + +#environment-tab .filter { + -fx-padding: 1px; +} +#environment-tab .tree-table-row-cell:group .key-cell { + -fx-text-fill: -palette-color-fg; +} + +/****************************************************************************** +* Preferences +******************************************************************************/ + +#preferences-tab .group { + -fx-padding: 1em; +} +#preferences-tab .group > .header { + -fx-font-size: 1.1em; +} +#preferences-tab .group > FlowPane.content { + -fx-hgap: 1em; + -fx-vgap: 1em; +} +#preferences-tab .group > .content { + -fx-padding: 1em 1em 0 1em; +} + +/****************************************************************************** +* Custom Controls +******************************************************************************/ + +.tab-line { + -fx-background-color: -palette-color-neutral; +} +.tab-line .toggle-button, +.tab-line .button { + -fx-background-color: -palette-color-neutral-hover, -palette-color-neutral; + -fx-padding: 4px; + -fx-background-insets: 0, 0 0 2 0; + -fx-background-radius: 0; + -fx-graphic-text-gap: 6; + -fx-text-fill: -palette-color-fg; +} +.tab-line .toggle-button:hover { + -fx-background-color: -palette-color-neutral-hover, -palette-color-neutral-hover; +} +.tab-line .toggle-button:selected { + -fx-background-color: -palette-color-accent, -palette-color-neutral; +} +.tab-line .toggle-button:hover:selected { + -fx-background-color: -palette-color-accent, -palette-color-neutral-hover; +} + +.filter-field { + -fx-background-color: -palette-color-bg, -palette-color-bg; + -fx-background-insets: 0, 2; + -fx-padding: 2px; + -fx-spacing: 4px; + -fx-background-radius: 5px; +} +.filter-field:focus-within { + -fx-background-color: -palette-color-accent, -palette-color-bg; +} +.filter-field > .text-field { + -fx-background-color: -palette-color-bg; + -fx-background-insets: 0; + -fx-background-radius: 3px; + -fx-text-fill: -palette-color-fg; + -fx-padding: 2px 6px 2px 6px; +} +.filter-field > .button { + -fx-background-color: -palette-color-bg; + -fx-padding: 0 4px 0 0; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-graphic-text-gap: 0; + -fx-text-fill: -palette-color-fg; + -fx-cursor: hand; +} +.filter-field > .clear-button > .icon { + /* MaterialSymbols.CANCEL */ + -fx-shape: "m339-288 141-141 141 141 51-51-141-141 141-141-51-51-141 141-141-141-51 51 141 141-141 141 51 51ZM480-96q-79 0-149-30t-122.5-82.5Q156-261 126-331T96-480q0-80 30-149.5t82.5-122Q261-804 331-834t149-30q80 0 149.5 30t122 82.5Q804-699 834-629.5T864-480q0 79-30 149t-82.5 122.5Q699-156 629.5-126T480-96Zm0-72q130 0 221-91t91-221q0-130-91-221t-221-91q-130 0-221 91t-91 221q0 130 91 221t221 91Zm0-312Z"; + -fx-min-width: 1em; + -fx-max-width: 1em; + -fx-min-height: 1em; + -fx-max-height: 1em; + -fx-background-color: -palette-color-fg; + -fx-opacity: 0.8; +} + +.text-view { + -fx-padding: 0; +} +.text-view .text-area { + -fx-font-family: monospaced; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-border: 0; +} + +.hyperlink:empty { + -fx-cursor: default; +} + +.color-indicator { + -fx-stroke-width: 0.5; + -fx-stroke: -palette-color-fg; +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..eed88db --- /dev/null +++ b/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + io.github.mkpaz + devtoolsfx + 1.0-SNAPSHOT + pom + + connector + gui + demo + + + + UTF-8 + 21 + 21 + 21 + 22 + + + + + + org.openjfx + javafx-controls + ${javafxVersion} + + + + org.jspecify + jspecify + 1.0.0 + + + + org.junit.jupiter + junit-jupiter-api + 5.9.1 + + + org.junit.jupiter + junit-jupiter-engine + 5.9.1 + + + org.junit.jupiter + junit-jupiter-params + 5.9.1 + + + org.assertj + assertj-core + 3.23.1 + + + org.mockito + mockito-core + 4.8.1 + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + ${javaVersion} + + + + + + +