From ce211c82a49f4ceec9b5a9cfc08aeae6f9cbd2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Sat, 1 Aug 2026 15:54:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=82=E6=AD=A5=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E6=B5=8B=E9=87=8F=E4=B8=8E=E7=9B=91=E5=90=AC=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/ui/async/LoadingButtonBinder.java | 105 ++++++++++++++---- .../system/ui/async/OperationStatusPane.java | 24 +++- .../ui/SharedPaneAccessibilityTest.java | 25 +++++ .../ui/async/LoadingButtonTextTest.java | 67 +++++++++++ 4 files changed, 197 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java index 4aa259b..874d204 100644 --- a/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java +++ b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java @@ -2,9 +2,13 @@ package com.example.workutils.system.ui.async; import javafx.beans.value.ChangeListener; import javafx.css.PseudoClass; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.Region; +import javafx.scene.text.Text; import java.util.Objects; @@ -12,6 +16,7 @@ public final class LoadingButtonBinder { private static final double SPINNER_SIZE = 14; private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running"); + private static final Object BINDING_KEY = new Object(); private LoadingButtonBinder() { } @@ -20,18 +25,13 @@ public final class LoadingButtonBinder { String idleText) { Objects.requireNonNull(button, "button"); Objects.requireNonNull(controller, "controller"); - Node idleGraphic = button.getGraphic(); - String idleAccessibleText = button.getAccessibleText(); - preserveIdleWidth(button); - ProgressIndicator spinner = createSpinner(); - - ChangeListener listener = - (observable, oldValue, snapshot) -> - apply(button, idleGraphic, idleAccessibleText, spinner, - snapshot, idleText); - controller.snapshotProperty().addListener(listener); - apply(button, idleGraphic, idleAccessibleText, spinner, - controller.snapshot(), idleText); + Objects.requireNonNull(idleText, "idleText"); + ButtonBinding binding = (ButtonBinding) button.getProperties().get(BINDING_KEY); + if (binding == null) { + binding = new ButtonBinding(button, idleText); + button.getProperties().put(BINDING_KEY, binding); + } + binding.bind(controller); } public static String textFor(OperationSnapshot snapshot, String idleText) { @@ -45,31 +45,52 @@ public final class LoadingButtonBinder { }; } - private static void apply(Button button, Node idleGraphic, String idleAccessibleText, - ProgressIndicator spinner, OperationSnapshot snapshot, - String idleText) { + private static void apply(ButtonBinding binding, OperationSnapshot snapshot) { + Button button = binding.button; boolean busy = snapshot.state() == OperationState.RUNNING || snapshot.state() == OperationState.CANCELLING; - String visibleText = textFor(snapshot, idleText); + String visibleText = textFor(snapshot, binding.idleText); button.setText(visibleText); - button.setAccessibleText(busy ? visibleText : idleAccessibleText); + button.setAccessibleText(busy ? visibleText : binding.idleAccessibleText); button.setDisable(busy); - button.setGraphic(busy ? spinner : idleGraphic); + button.setGraphic(busy ? binding.spinner : binding.idleGraphic); button.pseudoClassStateChanged(RUNNING, busy); } - private static void preserveIdleWidth(Button button) { - button.applyCss(); - double idleWidth = button.prefWidth(-1); + private static void preserveIdleWidth(ButtonBinding binding) { + Button button = binding.button; + Text text = new Text(binding.idleText); + text.setFont(button.getFont()); + double idleWidth = text.getLayoutBounds().getWidth(); + if (binding.idleGraphic != null) { + double graphicWidth = preferredWidth(binding.idleGraphic); + idleWidth += graphicWidth; + if (!binding.idleText.isEmpty() && graphicWidth > 0) { + idleWidth += button.getGraphicTextGap(); + } + } + Insets padding = button.getPadding(); + idleWidth += padding.getLeft() + padding.getRight(); double configuredMinWidth = button.getMinWidth(); if (configuredMinWidth >= 0) { idleWidth = Math.max(idleWidth, configuredMinWidth); } if (Double.isFinite(idleWidth) && idleWidth > 0) { - button.setMinWidth(idleWidth); + button.setMinWidth(Math.ceil(idleWidth)); } } + private static double preferredWidth(Node node) { + if (node instanceof Region region) { + double prefWidth = region.prefWidth(-1); + if (Double.isFinite(prefWidth) && prefWidth > 0) { + return prefWidth; + } + } + Bounds bounds = node.getLayoutBounds(); + return Math.max(0, bounds.getWidth()); + } + private static String trimEllipsis(String message) { String trimmed = message.trim(); while (trimmed.endsWith("…")) { @@ -86,4 +107,44 @@ public final class LoadingButtonBinder { spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE); return spinner; } + + private static final class ButtonBinding { + + private final Button button; + private final String idleText; + private final Node idleGraphic; + private final String idleAccessibleText; + private final ProgressIndicator spinner = createSpinner(); + private AsyncOperationController controller; + private ChangeListener snapshotListener; + + private ButtonBinding(Button button, String idleText) { + this.button = button; + this.idleText = idleText; + idleGraphic = button.getGraphic(); + idleAccessibleText = button.getAccessibleText(); + button.sceneProperty().addListener( + (observable, oldScene, newScene) -> recalibrate(newScene != null)); + button.skinProperty().addListener( + (observable, oldSkin, newSkin) -> recalibrate(newSkin != null)); + recalibrate(button.getScene() != null); + } + + private void bind(AsyncOperationController newController) { + if (controller != null && snapshotListener != null) { + controller.snapshotProperty().removeListener(snapshotListener); + } + controller = newController; + snapshotListener = (observable, oldValue, snapshot) -> apply(this, snapshot); + controller.snapshotProperty().addListener(snapshotListener); + apply(this, controller.snapshot()); + } + + private void recalibrate(boolean applyCss) { + if (applyCss) { + button.applyCss(); + } + preserveIdleWidth(this); + } + } } diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java index 44716a1..a4d9e61 100644 --- a/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java +++ b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java @@ -1,6 +1,7 @@ package com.example.workutils.system.ui.async; import com.example.workutils.system.ui.Theme; +import javafx.beans.value.ChangeListener; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -16,6 +17,9 @@ public final class OperationStatusPane extends HBox { private final Label messageLabel = new Label("就绪"); private final Label percentageLabel = new Label(); private final Button cancelButton; + private final AsyncOperationController controller; + private final ChangeListener snapshotListener; + private boolean disposed; /** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */ @Deprecated @@ -33,6 +37,7 @@ public final class OperationStatusPane extends HBox { public OperationStatusPane(AsyncOperationController controller, boolean cancellable) { super(Theme.S2); Objects.requireNonNull(controller, "controller"); + this.controller = controller; setAlignment(Pos.CENTER_LEFT); getStyleClass().add("operation-status"); @@ -56,11 +61,26 @@ public final class OperationStatusPane extends HBox { } else { cancelButton = null; } - controller.snapshotProperty().addListener( - (observable, oldValue, value) -> render(value)); + snapshotListener = (observable, oldValue, value) -> render(value); + controller.snapshotProperty().addListener(snapshotListener); render(controller.snapshot()); } + /** + * Releases controller listeners when this pane is no longer used. + * Calling this method more than once is safe. + */ + public void dispose() { + if (disposed) { + return; + } + disposed = true; + controller.snapshotProperty().removeListener(snapshotListener); + if (cancelButton != null) { + cancelButton.setOnAction(null); + } + } + private void render(OperationSnapshot snapshot) { progressBar.setProgress(snapshot.progress()); boolean active = snapshot.state() == OperationState.RUNNING diff --git a/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java b/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java index e8e9ab5..935dba7 100644 --- a/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java +++ b/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java @@ -11,6 +11,7 @@ import javafx.collections.FXCollections; import javafx.scene.control.Button; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.layout.StackPane; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -22,6 +23,7 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class SharedPaneAccessibilityTest { @@ -75,6 +77,8 @@ class SharedPaneAccessibilityTest { AsyncOperationController controller = new AsyncOperationController<>(pendingOperation::set, uiUpdates::add); OperationStatusPane pane = new OperationStatusPane(controller, true); + assertNull(pane.getScene(), + "an initially unattached pane must still observe its controller"); List