diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXProgressBar.java b/HMCL/src/main/java/com/jfoenix/controls/JFXProgressBar.java index 7e6a6ae46b..38ff0a2607 100644 --- a/HMCL/src/main/java/com/jfoenix/controls/JFXProgressBar.java +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXProgressBar.java @@ -20,8 +20,6 @@ package com.jfoenix.controls; import com.jfoenix.skins.JFXProgressBarSkin; -import javafx.beans.property.DoubleProperty; -import javafx.beans.property.SimpleDoubleProperty; import javafx.scene.control.ProgressBar; import javafx.scene.control.Skin; @@ -55,22 +53,4 @@ private void initialize() { setPrefWidth(200); getStyleClass().add(DEFAULT_STYLE_CLASS); } - - - private DoubleProperty secondaryProgress; - - public DoubleProperty secondaryProgressProperty() { - if (secondaryProgress == null) { - secondaryProgress = new SimpleDoubleProperty(this, "secondaryProgress", INDETERMINATE_PROGRESS); - } - return secondaryProgress; - } - - public double getSecondaryProgress() { - return secondaryProgress == null ? INDETERMINATE_PROGRESS : secondaryProgress.get(); - } - - public void setSecondaryProgress(double secondaryProgress) { - secondaryProgressProperty().set(secondaryProgress); - } } diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXProgressBarSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXProgressBarSkin.java index bc844f62a7..215bfe72d7 100644 --- a/HMCL/src/main/java/com/jfoenix/skins/JFXProgressBarSkin.java +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXProgressBarSkin.java @@ -20,45 +20,55 @@ package com.jfoenix.skins; import com.jfoenix.controls.JFXProgressBar; -import com.jfoenix.utils.JFXNodeUtils; import com.jfoenix.utils.TreeShowingProperty; -import javafx.animation.*; -import javafx.geometry.Insets; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; import javafx.scene.Node; -import javafx.scene.control.ProgressIndicator; import javafx.scene.control.skin.ProgressIndicatorSkin; -import javafx.scene.layout.*; -import javafx.scene.paint.Color; +import javafx.scene.layout.Region; import javafx.util.Duration; -/// # Material Design ProgressBar Skin +/// # Material Design 3 ProgressBar Skin /// /// @author Shadi Shaheen /// @version 2.0 /// @since 2017-10-06 public class JFXProgressBarSkin extends ProgressIndicatorSkin { - private StackPane track; - private StackPane secondaryBar; - private StackPane bar; - private double barWidth = 0; - private double secondaryBarWidth = 0; - private Timeline indeterminateTransition; - private Region clip; + private static final double INDICATOR_HEIGHT = 4; + private static final double DETERMINATE_MIN_ACTIVE_WIDTH = 4; + private static final double TRACK_GAP = 4; + private static final double STOP_INDICATOR_SIZE = 4; + + private static final double INDETERMINATE_INITIAL_START_FACTOR = 0.0; + private static final double INDETERMINATE_INITIAL_END_FACTOR = 0.18; + + private final Region leadingTrack = new Region(); + private final Region trailingTrack = new Region(); + private final Region activeIndicator = new Region(); + private final Region stopIndicator = new Region(); + private final DoubleProperty indeterminateSegmentStartFactor = new SimpleDoubleProperty(INDETERMINATE_INITIAL_START_FACTOR); + private final DoubleProperty indeterminateSegmentEndFactor = new SimpleDoubleProperty(INDETERMINATE_INITIAL_END_FACTOR); private final TreeShowingProperty treeShowingProperty; + private Timeline indeterminateTransition; + public JFXProgressBarSkin(JFXProgressBar bar) { super(bar); this.treeShowingProperty = new TreeShowingProperty(bar); - bar.widthProperty().addListener(observable -> { - updateProgress(); - updateSecondaryProgress(); - }); + initializeNodes(); + + indeterminateSegmentStartFactor.addListener(observable -> getSkinnable().requestLayout()); + indeterminateSegmentEndFactor.addListener(observable -> getSkinnable().requestLayout()); + bar.widthProperty().addListener(observable -> updateProgress()); - registerChangeListener(bar.progressProperty(), (obs) -> updateProgress()); - registerChangeListener(bar.secondaryProgressProperty(), obs -> updateSecondaryProgress()); + registerChangeListener(bar.progressProperty(), obs -> updateProgress()); registerChangeListener(bar.visibleProperty(), obs -> updateAnimation()); registerChangeListener(bar.parentProperty(), obs -> updateAnimation()); registerChangeListener(bar.sceneProperty(), obs -> updateAnimation()); @@ -66,30 +76,29 @@ public JFXProgressBarSkin(JFXProgressBar bar) { unregisterChangeListeners(treeShowingProperty); unregisterChangeListeners(bar.indeterminateProperty()); - registerChangeListener(treeShowingProperty, obs -> this.updateAnimation()); + registerChangeListener(treeShowingProperty, obs -> updateAnimation()); registerChangeListener(bar.indeterminateProperty(), obs -> initialize()); initialize(); - - getSkinnable().requestLayout(); } - protected void initialize() { - - track = new StackPane(); - track.getStyleClass().setAll("track"); - - bar = new StackPane(); - bar.getStyleClass().setAll("bar"); - - secondaryBar = new StackPane(); - secondaryBar.getStyleClass().setAll("secondary-bar"); + private void initializeNodes() { + configureRegion(leadingTrack, "track"); + configureRegion(trailingTrack, "track"); + configureRegion(activeIndicator, "active-indicator"); + configureRegion(stopIndicator, "stop-indicator"); + getChildren().setAll(leadingTrack, trailingTrack, activeIndicator, stopIndicator); + } - clip = new Region(); - clip.setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY))); - bar.backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(bar.getBackground(), clip)); + private void configureRegion(Region region, String styleClass) { + region.getStyleClass().setAll(styleClass); + region.setManaged(false); + } - getChildren().setAll(track, secondaryBar, bar); + protected void initialize() { + resetIndeterminateGeometry(); + updateAnimation(); + updateProgress(); } @Override @@ -99,12 +108,13 @@ public double computeBaselineOffset(double topInset, double rightInset, double b @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { - return Math.max(100, leftInset + bar.prefWidth(getSkinnable().getWidth()) + rightInset); + double prefWidth = getSkinnable().getPrefWidth(); + return leftInset + (prefWidth > 0 ? prefWidth : 100) + rightInset; } @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { - return topInset + bar.prefHeight(width) + bottomInset; + return topInset + INDICATOR_HEIGHT + bottomInset; } @Override @@ -114,111 +124,166 @@ protected double computeMaxWidth(double height, double topInset, double rightIns @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { - return getSkinnable().prefHeight(width); + return topInset + INDICATOR_HEIGHT + bottomInset; } @Override protected void layoutChildren(double x, double y, double w, double h) { - track.resizeRelocate(x, y, w, h); - secondaryBar.resizeRelocate(x, y, secondaryBarWidth, h); - bar.resizeRelocate(x, y, getSkinnable().isIndeterminate() ? w : barWidth, h); - clip.resizeRelocate(0, 0, w, h); + double width = Math.max(0, w); + double height = Math.min(INDICATOR_HEIGHT, Math.max(0, h)); + double barY = y + Math.max(0, (h - height) / 2); if (getSkinnable().isIndeterminate()) { - createIndeterminateTimeline(); - if (JFXNodeUtils.isTreeShowing(getSkinnable())) { - indeterminateTransition.play(); - } - // apply clip - bar.setClip(clip); - } else if (indeterminateTransition != null) { - clearAnimation(); - // remove clip - bar.setClip(null); + layoutIndeterminate(x, barY, width, height); + } else { + layoutDeterminate(x, barY, width, height); } } - protected void updateSecondaryProgress() { - final JFXProgressBar control = (JFXProgressBar) getSkinnable(); - secondaryBarWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2 - * Math.min(1, Math.max(0, control.getSecondaryProgress()))) / 2.0F; - control.requestLayout(); + private void layoutDeterminate(double x, double y, double width, double height) { + double progress = clamp(getSkinnable().getProgress(), 0, 1); + boolean showActiveIndicator = progress > 0; + double gap = showActiveIndicator ? TRACK_GAP : 0; + double activeWidth = !showActiveIndicator + ? 0 + : progress >= 1 + ? width + : Math.min(width, Math.max(DETERMINATE_MIN_ACTIVE_WIDTH, progress * width)); + double trackX = x + activeWidth + gap; + double trackWidth = Math.max(0, width - activeWidth - gap); + + layoutRegion(leadingTrack, 0, 0, 0, 0, false); + layoutRegion(activeIndicator, x, y, activeWidth, height, showActiveIndicator && activeWidth > 0); + + boolean showTrack = progress < 1 && trackWidth > 0; + boolean showStopIndicator = progress > 0 && showTrack; + layoutRegion(trailingTrack, trackX, y, trackWidth, height, showTrack); + + double stopSize = Math.min(STOP_INDICATOR_SIZE, Math.min(trackWidth, height)); + double stopX = x + width - stopSize; + layoutRegion(stopIndicator, stopX, y, stopSize, stopSize, showStopIndicator && stopSize > 0); } - boolean wasIndeterminate = false; + private void layoutIndeterminate(double x, double y, double width, double height) { + double activeStart = indeterminateSegmentStartFactor.get() * width; + double activeEnd = indeterminateSegmentEndFactor.get() * width; + double visibleStart = clamp(activeStart, 0, width); + double visibleEnd = clamp(activeEnd, 0, width); + double visibleWidth = Math.max(0, visibleEnd - visibleStart); - protected void pauseTimeline(boolean pause) { - if (getSkinnable().isIndeterminate()) { - if (indeterminateTransition == null) { - createIndeterminateTimeline(); - } - if (pause) { - indeterminateTransition.pause(); - } else { - indeterminateTransition.play(); - } + layoutRegion(stopIndicator, 0, 0, 0, 0, false); + + if (visibleWidth <= 0) { + layoutRegion(activeIndicator, 0, 0, 0, 0, false); + layoutRegion(leadingTrack, x, y, width, height, width > 0); + layoutRegion(trailingTrack, 0, 0, 0, 0, false); + return; } + + double leftTrackWidth = Math.max(0, visibleStart - TRACK_GAP); + double rightTrackX = x + visibleEnd + TRACK_GAP; + double rightTrackWidth = Math.max(0, width - visibleEnd - TRACK_GAP); + + layoutRegion(leadingTrack, x, y, leftTrackWidth, height, leftTrackWidth > 0); + layoutRegion(activeIndicator, x + visibleStart, y, visibleWidth, height, true); + layoutRegion(trailingTrack, rightTrackX, y, rightTrackWidth, height, rightTrackWidth > 0); + } + + private void layoutRegion(Region region, double x, double y, double width, double height, boolean visible) { + region.setVisible(visible); + if (!visible) { + return; + } + region.resizeRelocate(x, y, Math.max(0, width), Math.max(0, height)); } private void updateAnimation() { - final boolean isTreeShowing = treeShowingProperty.get(); - if (indeterminateTransition != null) { - pauseTimeline(!isTreeShowing); - } else if (isTreeShowing) { + if (!getSkinnable().isIndeterminate()) { + clearAnimation(); + return; + } + + if (indeterminateTransition == null) { createIndeterminateTimeline(); } + + if (treeShowingProperty.get()) { + indeterminateTransition.play(); + } else { + indeterminateTransition.pause(); + } } private void updateProgress() { - final ProgressIndicator control = getSkinnable(); - final boolean isIndeterminate = control.isIndeterminate(); - if (!(isIndeterminate && wasIndeterminate)) { - barWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2 - * Math.min(1, Math.max(0, control.getProgress()))) / 2.0F; - control.requestLayout(); - } - wasIndeterminate = isIndeterminate; + getSkinnable().requestLayout(); + } + + private void resetIndeterminateGeometry() { + indeterminateSegmentStartFactor.set(INDETERMINATE_INITIAL_START_FACTOR); + indeterminateSegmentEndFactor.set(INDETERMINATE_INITIAL_END_FACTOR); + } + + void setIndeterminateSegmentForTesting(double startFactor, double endFactor) { + indeterminateSegmentStartFactor.set(startFactor); + indeterminateSegmentEndFactor.set(Math.max(startFactor, endFactor)); } private void createIndeterminateTimeline() { - if (indeterminateTransition != null) { - clearAnimation(); - } - double dur = 1; - ProgressIndicator control = getSkinnable(); - final double w = control.getWidth() - (snappedLeftInset() + snappedRightInset()); - indeterminateTransition = new Timeline(new KeyFrame( - Duration.ZERO, - new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_IN), - new KeyValue(clip.translateXProperty(), -w / 2, Interpolator.LINEAR) - ), + clearAnimation(); + resetIndeterminateGeometry(); + + indeterminateTransition = new Timeline( + new KeyFrame( + Duration.ZERO, + new KeyValue(indeterminateSegmentStartFactor, 0.0, Interpolator.LINEAR), + new KeyValue(indeterminateSegmentEndFactor, 0.18, Interpolator.EASE_OUT) + ), + new KeyFrame( + Duration.seconds(0.42), + new KeyValue(indeterminateSegmentStartFactor, 0.0, Interpolator.EASE_BOTH), + new KeyValue(indeterminateSegmentEndFactor, 0.46, Interpolator.EASE_BOTH) + ), + new KeyFrame( + Duration.seconds(0.95), + new KeyValue(indeterminateSegmentStartFactor, 0.14, Interpolator.EASE_BOTH), + new KeyValue(indeterminateSegmentEndFactor, 0.76, Interpolator.EASE_BOTH) + ), new KeyFrame( - Duration.seconds(0.5 * dur), - new KeyValue(clip.scaleXProperty(), 0.4, Interpolator.LINEAR) + Duration.seconds(1.42), + new KeyValue(indeterminateSegmentStartFactor, 0.44, Interpolator.EASE_BOTH), + new KeyValue(indeterminateSegmentEndFactor, 0.94, Interpolator.EASE_IN) ), new KeyFrame( - Duration.seconds(0.9 * dur), - new KeyValue(clip.translateXProperty(), w / 2, Interpolator.LINEAR) + Duration.seconds(1.78), + new KeyValue(indeterminateSegmentStartFactor, 0.82, Interpolator.EASE_IN), + new KeyValue(indeterminateSegmentEndFactor, 1.0, Interpolator.EASE_IN) ), new KeyFrame( - Duration.seconds(1 * dur), - new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_OUT) - )); + Duration.seconds(2.0), + new KeyValue(indeterminateSegmentStartFactor, 1.02, Interpolator.EASE_IN), + new KeyValue(indeterminateSegmentEndFactor, 1.02, Interpolator.EASE_IN) + ) + ); indeterminateTransition.setCycleCount(Timeline.INDEFINITE); } private void clearAnimation() { + if (indeterminateTransition == null) { + return; + } indeterminateTransition.stop(); indeterminateTransition.getKeyFrames().clear(); indeterminateTransition = null; } + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + @Override public void dispose() { super.dispose(); treeShowingProperty.dispose(); - if (indeterminateTransition != null) { - clearAnimation(); - } + clearAnimation(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java index f5f7e52e16..d4982fc1c0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java @@ -310,6 +310,7 @@ private Cell() { bar.minWidthProperty().bind(barWidth); bar.prefWidthProperty().bind(barWidth); bar.maxWidthProperty().bind(barWidth); + BorderPane.setMargin(bar, new Insets(2, 0, 0, 0)); setGraphic(pane); } diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 799882b6d8..5297f8bca7 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -913,24 +913,25 @@ * * *******************************************************************************/ +.jfx-progress-bar { + -fx-min-height: 4px; + -fx-pref-height: 4px; + -fx-max-height: 4px; +} + .jfx-progress-bar > .track { -fx-background-color: -monet-secondary-container; } -.jfx-progress-bar > .bar, -.jfx-progress-bar:indeterminate > .bar{ +.jfx-progress-bar > .active-indicator, +.jfx-progress-bar > .stop-indicator { -fx-background-color: -monet-primary; - -fx-padding: 1.5; -} - -.jfx-progress-bar > .secondary-bar, -.jfx-progress-bar:indeterminate > .secondary-bar { - -fx-background-color: -monet-secondary; } .jfx-progress-bar > .track, -.jfx-progress-bar > .bar { - -fx-background-radius: 0; +.jfx-progress-bar > .active-indicator, +.jfx-progress-bar > .stop-indicator { + -fx-background-radius: 999; -fx-background-insets: 0; } diff --git a/HMCL/src/test/java/com/jfoenix/skins/JFXProgressBarSkinTest.java b/HMCL/src/test/java/com/jfoenix/skins/JFXProgressBarSkinTest.java new file mode 100644 index 0000000000..7d74355eab --- /dev/null +++ b/HMCL/src/test/java/com/jfoenix/skins/JFXProgressBarSkinTest.java @@ -0,0 +1,225 @@ +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXProgressBar; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import org.jackhuang.hmcl.JavaFXLauncher; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class JFXProgressBarSkinTest { + private static final double TEST_WIDTH = 100; + private static final double TEST_HEIGHT = 4; + private static final double DELTA = 0.001; + + @BeforeAll + static void startJavaFx() { + JavaFXLauncher.start(); + assumeTrue(JavaFXLauncher.isStarted(), "JavaFX toolkit is unavailable in this environment"); + } + + @Test + void determinateZeroProgressHidesActiveIndicatorAndUsesFullTrack() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createProbe(0); + + assertFalse(probe.activeIndicator().isVisible()); + assertEquals(0.0, probe.activeIndicator().getWidth(), DELTA); + + List visibleTracks = probe.visibleTracks(); + assertEquals(1, visibleTracks.size()); + assertEquals(0.0, visibleTracks.get(0).getLayoutX(), DELTA); + assertEquals(100.0, visibleTracks.get(0).getWidth(), DELTA); + + assertFalse(probe.stopIndicator().isVisible()); + }); + } + + @Test + void determinateCompleteHidesTrackAndStopIndicator() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createProbe(1.0); + + assertEquals(TEST_WIDTH, probe.activeIndicator().getWidth(), DELTA); + assertTrue(probe.visibleTracks().isEmpty()); + assertFalse(probe.stopIndicator().isVisible()); + }); + } + + @Test + void indeterminateStartsWithActiveIndicatorAndNoStopIndicator() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createProbe(ProgressIndicator.INDETERMINATE_PROGRESS); + + assertEquals(0.0, probe.activeIndicator().getLayoutX(), DELTA); + assertEquals(18.0, probe.activeIndicator().getWidth(), DELTA); + + List visibleTracks = probe.visibleTracks(); + assertEquals(1, visibleTracks.size()); + assertEquals(22.0, visibleTracks.get(0).getLayoutX(), DELTA); + assertEquals(78.0, visibleTracks.get(0).getWidth(), DELTA); + + assertFalse(probe.stopIndicator().isVisible()); + }); + } + + @Test + void indeterminateMidFlightShowsTracksOnBothSidesWithFourPixelGap() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createIndeterminateProbe(0.24, 0.68); + + assertEquals(24.0, probe.activeIndicator().getLayoutX(), DELTA); + assertEquals(44.0, probe.activeIndicator().getWidth(), DELTA); + + List visibleTracks = probe.visibleTracks(); + assertEquals(2, visibleTracks.size()); + assertEquals(0.0, visibleTracks.get(0).getLayoutX(), DELTA); + assertEquals(20.0, visibleTracks.get(0).getWidth(), DELTA); + assertEquals(72.0, visibleTracks.get(1).getLayoutX(), DELTA); + assertEquals(28.0, visibleTracks.get(1).getWidth(), DELTA); + + assertFalse(probe.stopIndicator().isVisible()); + }); + } + + @Test + void indeterminateNearExitHidesTrailingTrack() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createIndeterminateProbe(0.86, 1.02); + + assertEquals(86.0, probe.activeIndicator().getLayoutX(), DELTA); + assertEquals(14.0, probe.activeIndicator().getWidth(), DELTA); + + List visibleTracks = probe.visibleTracks(); + assertEquals(1, visibleTracks.size()); + assertEquals(0.0, visibleTracks.get(0).getLayoutX(), DELTA); + assertEquals(82.0, visibleTracks.get(0).getWidth(), DELTA); + + assertFalse(probe.stopIndicator().isVisible()); + }); + } + + @Test + void determinatePositiveProgressUsesMinimumVisibleWidth() throws Exception { + runOnFxThreadAndWait(() -> { + LayoutProbe probe = createProbe(0.01); + + assertTrue(probe.activeIndicator().isVisible()); + assertEquals(0.0, probe.activeIndicator().getLayoutX(), DELTA); + assertEquals(4.0, probe.activeIndicator().getWidth(), DELTA); + + List visibleTracks = probe.visibleTracks(); + assertEquals(1, visibleTracks.size()); + assertEquals(8.0, visibleTracks.get(0).getLayoutX(), DELTA); + assertEquals(92.0, visibleTracks.get(0).getWidth(), DELTA); + + assertTrue(probe.stopIndicator().isVisible()); + }); + } + + private static LayoutProbe createProbe(double progress) { + JFXProgressBar progressBar = new JFXProgressBar(progress); + progressBar.setMinSize(TEST_WIDTH, TEST_HEIGHT); + progressBar.setPrefSize(TEST_WIDTH, TEST_HEIGHT); + progressBar.setMaxSize(TEST_WIDTH, TEST_HEIGHT); + + Pane root = new Pane(progressBar); + new Scene(root, TEST_WIDTH, 16); + + root.applyCss(); + progressBar.applyCss(); + progressBar.resizeRelocate(0, 6, TEST_WIDTH, TEST_HEIGHT); + progressBar.layout(); + + Region activeIndicator = lookupRegion(progressBar, ".active-indicator"); + Region stopIndicator = lookupRegion(progressBar, ".stop-indicator"); + List tracks = progressBar.lookupAll(".track").stream() + .map(Region.class::cast) + .sorted(Comparator.comparingDouble(Node::getLayoutX)) + .toList(); + + return new LayoutProbe(activeIndicator, tracks, stopIndicator); + } + + private static Region lookupRegion(JFXProgressBar progressBar, String selector) { + Node node = progressBar.lookup(selector); + assertNotNull(node, () -> "Missing node for selector " + selector); + return (Region) node; + } + + private static void runOnFxThreadAndWait(CheckedRunnable runnable) throws Exception { + if (Platform.isFxApplicationThread()) { + runnable.run(); + return; + } + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Platform.runLater(() -> { + try { + runnable.run(); + } catch (Throwable throwable) { + failure.set(throwable); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for JavaFX work to finish"); + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + } + + private static LayoutProbe createIndeterminateProbe(double startFactor, double endFactor) { + JFXProgressBar progressBar = new JFXProgressBar(ProgressIndicator.INDETERMINATE_PROGRESS); + progressBar.setMinSize(TEST_WIDTH, TEST_HEIGHT); + progressBar.setPrefSize(TEST_WIDTH, TEST_HEIGHT); + progressBar.setMaxSize(TEST_WIDTH, TEST_HEIGHT); + + Pane root = new Pane(progressBar); + new Scene(root, TEST_WIDTH, 16); + + root.applyCss(); + progressBar.applyCss(); + ((JFXProgressBarSkin) progressBar.getSkin()).setIndeterminateSegmentForTesting(startFactor, endFactor); + progressBar.resizeRelocate(0, 6, TEST_WIDTH, TEST_HEIGHT); + progressBar.layout(); + + Region activeIndicator = lookupRegion(progressBar, ".active-indicator"); + Region stopIndicator = lookupRegion(progressBar, ".stop-indicator"); + List tracks = progressBar.lookupAll(".track").stream() + .map(Region.class::cast) + .sorted(Comparator.comparingDouble(Node::getLayoutX)) + .toList(); + + return new LayoutProbe(activeIndicator, tracks, stopIndicator); + } + + private record LayoutProbe(Region activeIndicator, List tracks, Region stopIndicator) { + private List visibleTracks() { + return tracks.stream().filter(Node::isVisible).toList(); + } + } + + @FunctionalInterface + private interface CheckedRunnable { + void run() throws Exception; + } +}