From edba13531d8858b98cb4e0ada67ffd32f3b3d482 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:36:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BC=82=E6=AD=A5=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=A7=86=E8=A7=89=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/ui/async/LoadingButtonBinder.java | 41 ++++++++-- .../system/ui/async/OperationStatusPane.java | 60 ++++++++++---- .../ui/SharedPaneAccessibilityTest.java | 47 ++++++++++- .../ui/async/LoadingButtonTextTest.java | 80 +++++++++++++++++++ 4 files changed, 202 insertions(+), 26 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 2bf6d03..4aa259b 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 @@ -1,6 +1,7 @@ package com.example.workutils.system.ui.async; import javafx.beans.value.ChangeListener; +import javafx.css.PseudoClass; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ProgressIndicator; @@ -9,6 +10,9 @@ import java.util.Objects; public final class LoadingButtonBinder { + private static final double SPINNER_SIZE = 14; + private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running"); + private LoadingButtonBinder() { } @@ -17,13 +21,17 @@ public final class LoadingButtonBinder { 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, spinner, snapshot, idleText); + apply(button, idleGraphic, idleAccessibleText, spinner, + snapshot, idleText); controller.snapshotProperty().addListener(listener); - apply(button, idleGraphic, spinner, controller.snapshot(), idleText); + apply(button, idleGraphic, idleAccessibleText, spinner, + controller.snapshot(), idleText); } public static String textFor(OperationSnapshot snapshot, String idleText) { @@ -37,13 +45,29 @@ public final class LoadingButtonBinder { }; } - private static void apply(Button button, Node idleGraphic, ProgressIndicator spinner, - OperationSnapshot snapshot, String idleText) { + private static void apply(Button button, Node idleGraphic, String idleAccessibleText, + ProgressIndicator spinner, OperationSnapshot snapshot, + String idleText) { boolean busy = snapshot.state() == OperationState.RUNNING || snapshot.state() == OperationState.CANCELLING; - button.setText(textFor(snapshot, idleText)); + String visibleText = textFor(snapshot, idleText); + button.setText(visibleText); + button.setAccessibleText(busy ? visibleText : idleAccessibleText); button.setDisable(busy); button.setGraphic(busy ? spinner : idleGraphic); + button.pseudoClassStateChanged(RUNNING, busy); + } + + private static void preserveIdleWidth(Button button) { + button.applyCss(); + double idleWidth = button.prefWidth(-1); + double configuredMinWidth = button.getMinWidth(); + if (configuredMinWidth >= 0) { + idleWidth = Math.max(idleWidth, configuredMinWidth); + } + if (Double.isFinite(idleWidth) && idleWidth > 0) { + button.setMinWidth(idleWidth); + } } private static String trimEllipsis(String message) { @@ -56,9 +80,10 @@ public final class LoadingButtonBinder { private static ProgressIndicator createSpinner() { ProgressIndicator spinner = new ProgressIndicator(); - spinner.setPrefSize(14, 14); - spinner.setMinSize(14, 14); - spinner.setMaxSize(14, 14); + spinner.getStyleClass().add("loading-spinner"); + spinner.setPrefSize(SPINNER_SIZE, SPINNER_SIZE); + spinner.setMinSize(SPINNER_SIZE, SPINNER_SIZE); + spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE); return spinner; } } 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 20b2a50..44716a1 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 @@ -8,39 +8,53 @@ import javafx.scene.control.ProgressBar; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import java.util.Objects; + public final class OperationStatusPane extends HBox { private final ProgressBar progressBar = new ProgressBar(0); private final Label messageLabel = new Label("就绪"); + private final Label percentageLabel = new Label(); private final Button cancelButton; - private final boolean cancellable; + /** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */ + @Deprecated public OperationStatusPane(AsyncOperationController controller, int fontSize) { - this(controller, fontSize, true); + this(controller, true); } + /** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */ + @Deprecated public OperationStatusPane(AsyncOperationController controller, int fontSize, boolean cancellable) { + this(controller, cancellable); + } + + public OperationStatusPane(AsyncOperationController controller, boolean cancellable) { super(Theme.S2); - this.cancellable = cancellable; + Objects.requireNonNull(controller, "controller"); setAlignment(Pos.CENTER_LEFT); getStyleClass().add("operation-status"); progressBar.setPrefWidth(240); progressBar.setAccessibleText("任务进度"); - messageLabel.setFont(Theme.base(fontSize)); - messageLabel.setTextFill(Theme.TEXT_SECONDARY); + messageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); messageLabel.setAccessibleText("任务状态"); + percentageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); + percentageLabel.setAccessibleText("任务进度百分比"); + percentageLabel.setFocusTraversable(false); HBox.setHgrow(messageLabel, Priority.ALWAYS); - cancelButton = com.example.workutils.system.ui.StyledButton.danger( - "停止", Theme.base(fontSize)); - cancelButton.setAccessibleText("停止当前任务"); - cancelButton.setOnAction(event -> controller.cancel()); - - getChildren().addAll(progressBar, messageLabel); + getChildren().addAll(progressBar, messageLabel, percentageLabel); if (cancellable) { + cancelButton = new Button("停止"); + cancelButton.getStyleClass().add(Theme.CSS_BTN_DANGER); + cancelButton.setAccessibleText("停止当前任务"); + cancelButton.setFocusTraversable(true); + cancelButton.setOnAction(event -> controller.cancel()); getChildren().add(cancelButton); + } else { + cancelButton = null; } controller.snapshotProperty().addListener( (observable, oldValue, value) -> render(value)); @@ -49,16 +63,30 @@ public final class OperationStatusPane extends HBox { private void render(OperationSnapshot snapshot) { progressBar.setProgress(snapshot.progress()); + boolean active = snapshot.state() == OperationState.RUNNING + || snapshot.state() == OperationState.CANCELLING; + boolean determinate = active && Double.isFinite(snapshot.progress()) + && snapshot.progress() >= 0; + percentageLabel.setText(determinate + ? Math.round(snapshot.progress() * 100) + "%" + : ""); + percentageLabel.setVisible(determinate); + percentageLabel.setManaged(determinate); messageLabel.setText(snapshot.message().isBlank() ? defaultMessage(snapshot.state()) : snapshot.message()); - String stateDescription = "任务状态:" + messageLabel.getText(); + String stateDescription = "任务状态:" + messageLabel.getText() + + (determinate ? ",进度 " + percentageLabel.getText() : ""); setAccessibleText(stateDescription); messageLabel.setAccessibleText(stateDescription); progressBar.setAccessibleText("任务进度:" + stateDescription); - cancelButton.setVisible(cancellable - && snapshot.state() == OperationState.RUNNING); - cancelButton.setManaged(cancelButton.isVisible()); - cancelButton.setDisable(snapshot.state() != OperationState.RUNNING); + percentageLabel.setAccessibleText(determinate + ? "任务进度:" + percentageLabel.getText() + : "任务进度未知"); + if (cancelButton != null) { + cancelButton.setVisible(snapshot.state() == OperationState.RUNNING); + cancelButton.setManaged(cancelButton.isVisible()); + cancelButton.setDisable(snapshot.state() != OperationState.RUNNING); + } getStyleClass().removeAll( "operation-status-success", "operation-status-error"); 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 b253cee..e8e9ab5 100644 --- a/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java +++ b/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java @@ -1,6 +1,7 @@ package com.example.workutils.system.ui; import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.OperationState; import com.example.workutils.system.ui.async.OperationStatusPane; import com.example.workutils.system.ui.history.ExportHistoryPane; import com.example.workutils.system.ui.history.HistoryFileCommands; @@ -17,16 +18,22 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.FutureTask; +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.assertTrue; class SharedPaneAccessibilityTest { @BeforeAll static void startJavaFx() throws Exception { CountDownLatch started = new CountDownLatch(1); - Platform.startup(started::countDown); + try { + Platform.startup(started::countDown); + } catch (IllegalStateException alreadyStarted) { + started.countDown(); + } started.await(); } @@ -48,7 +55,7 @@ class SharedPaneAccessibilityTest { runFx(() -> { AsyncOperationController controller = new AsyncOperationController<>(Runnable::run, Runnable::run); - OperationStatusPane pane = new OperationStatusPane(controller, 14, false); + OperationStatusPane pane = new OperationStatusPane(controller, false); long buttons = pane.getChildren().stream() .filter(Button.class::isInstance) @@ -59,6 +66,42 @@ class SharedPaneAccessibilityTest { }); } + @Test + void determinateStatusShowsPercentAndCancellableButtonRequestsCancellation() + throws Exception { + runFx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + List uiUpdates = new ArrayList<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, uiUpdates::add); + OperationStatusPane pane = new OperationStatusPane(controller, true); + + List