统一异步操作视觉状态
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.example.workutils.system.ui.async;
|
package com.example.workutils.system.ui.async;
|
||||||
|
|
||||||
import javafx.beans.value.ChangeListener;
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.ProgressIndicator;
|
import javafx.scene.control.ProgressIndicator;
|
||||||
@@ -9,6 +10,9 @@ import java.util.Objects;
|
|||||||
|
|
||||||
public final class LoadingButtonBinder {
|
public final class LoadingButtonBinder {
|
||||||
|
|
||||||
|
private static final double SPINNER_SIZE = 14;
|
||||||
|
private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running");
|
||||||
|
|
||||||
private LoadingButtonBinder() {
|
private LoadingButtonBinder() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,13 +21,17 @@ public final class LoadingButtonBinder {
|
|||||||
Objects.requireNonNull(button, "button");
|
Objects.requireNonNull(button, "button");
|
||||||
Objects.requireNonNull(controller, "controller");
|
Objects.requireNonNull(controller, "controller");
|
||||||
Node idleGraphic = button.getGraphic();
|
Node idleGraphic = button.getGraphic();
|
||||||
|
String idleAccessibleText = button.getAccessibleText();
|
||||||
|
preserveIdleWidth(button);
|
||||||
ProgressIndicator spinner = createSpinner();
|
ProgressIndicator spinner = createSpinner();
|
||||||
|
|
||||||
ChangeListener<OperationSnapshot> listener =
|
ChangeListener<OperationSnapshot> listener =
|
||||||
(observable, oldValue, snapshot) ->
|
(observable, oldValue, snapshot) ->
|
||||||
apply(button, idleGraphic, spinner, snapshot, idleText);
|
apply(button, idleGraphic, idleAccessibleText, spinner,
|
||||||
|
snapshot, idleText);
|
||||||
controller.snapshotProperty().addListener(listener);
|
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) {
|
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,
|
private static void apply(Button button, Node idleGraphic, String idleAccessibleText,
|
||||||
OperationSnapshot snapshot, String idleText) {
|
ProgressIndicator spinner, OperationSnapshot snapshot,
|
||||||
|
String idleText) {
|
||||||
boolean busy = snapshot.state() == OperationState.RUNNING
|
boolean busy = snapshot.state() == OperationState.RUNNING
|
||||||
|| snapshot.state() == OperationState.CANCELLING;
|
|| 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.setDisable(busy);
|
||||||
button.setGraphic(busy ? spinner : idleGraphic);
|
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) {
|
private static String trimEllipsis(String message) {
|
||||||
@@ -56,9 +80,10 @@ public final class LoadingButtonBinder {
|
|||||||
|
|
||||||
private static ProgressIndicator createSpinner() {
|
private static ProgressIndicator createSpinner() {
|
||||||
ProgressIndicator spinner = new ProgressIndicator();
|
ProgressIndicator spinner = new ProgressIndicator();
|
||||||
spinner.setPrefSize(14, 14);
|
spinner.getStyleClass().add("loading-spinner");
|
||||||
spinner.setMinSize(14, 14);
|
spinner.setPrefSize(SPINNER_SIZE, SPINNER_SIZE);
|
||||||
spinner.setMaxSize(14, 14);
|
spinner.setMinSize(SPINNER_SIZE, SPINNER_SIZE);
|
||||||
|
spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE);
|
||||||
return spinner;
|
return spinner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,39 +8,53 @@ import javafx.scene.control.ProgressBar;
|
|||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class OperationStatusPane extends HBox {
|
public final class OperationStatusPane extends HBox {
|
||||||
|
|
||||||
private final ProgressBar progressBar = new ProgressBar(0);
|
private final ProgressBar progressBar = new ProgressBar(0);
|
||||||
private final Label messageLabel = new Label("就绪");
|
private final Label messageLabel = new Label("就绪");
|
||||||
|
private final Label percentageLabel = new Label();
|
||||||
private final Button cancelButton;
|
private final Button cancelButton;
|
||||||
private final boolean cancellable;
|
|
||||||
|
|
||||||
|
/** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */
|
||||||
|
@Deprecated
|
||||||
public OperationStatusPane(AsyncOperationController<?> controller, int fontSize) {
|
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,
|
public OperationStatusPane(AsyncOperationController<?> controller, int fontSize,
|
||||||
boolean cancellable) {
|
boolean cancellable) {
|
||||||
|
this(controller, cancellable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OperationStatusPane(AsyncOperationController<?> controller, boolean cancellable) {
|
||||||
super(Theme.S2);
|
super(Theme.S2);
|
||||||
this.cancellable = cancellable;
|
Objects.requireNonNull(controller, "controller");
|
||||||
setAlignment(Pos.CENTER_LEFT);
|
setAlignment(Pos.CENTER_LEFT);
|
||||||
getStyleClass().add("operation-status");
|
getStyleClass().add("operation-status");
|
||||||
|
|
||||||
progressBar.setPrefWidth(240);
|
progressBar.setPrefWidth(240);
|
||||||
progressBar.setAccessibleText("任务进度");
|
progressBar.setAccessibleText("任务进度");
|
||||||
messageLabel.setFont(Theme.base(fontSize));
|
messageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT);
|
||||||
messageLabel.setTextFill(Theme.TEXT_SECONDARY);
|
|
||||||
messageLabel.setAccessibleText("任务状态");
|
messageLabel.setAccessibleText("任务状态");
|
||||||
|
percentageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT);
|
||||||
|
percentageLabel.setAccessibleText("任务进度百分比");
|
||||||
|
percentageLabel.setFocusTraversable(false);
|
||||||
HBox.setHgrow(messageLabel, Priority.ALWAYS);
|
HBox.setHgrow(messageLabel, Priority.ALWAYS);
|
||||||
|
|
||||||
cancelButton = com.example.workutils.system.ui.StyledButton.danger(
|
getChildren().addAll(progressBar, messageLabel, percentageLabel);
|
||||||
"停止", Theme.base(fontSize));
|
|
||||||
cancelButton.setAccessibleText("停止当前任务");
|
|
||||||
cancelButton.setOnAction(event -> controller.cancel());
|
|
||||||
|
|
||||||
getChildren().addAll(progressBar, messageLabel);
|
|
||||||
if (cancellable) {
|
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);
|
getChildren().add(cancelButton);
|
||||||
|
} else {
|
||||||
|
cancelButton = null;
|
||||||
}
|
}
|
||||||
controller.snapshotProperty().addListener(
|
controller.snapshotProperty().addListener(
|
||||||
(observable, oldValue, value) -> render(value));
|
(observable, oldValue, value) -> render(value));
|
||||||
@@ -49,16 +63,30 @@ public final class OperationStatusPane extends HBox {
|
|||||||
|
|
||||||
private void render(OperationSnapshot snapshot) {
|
private void render(OperationSnapshot snapshot) {
|
||||||
progressBar.setProgress(snapshot.progress());
|
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())
|
messageLabel.setText(snapshot.message().isBlank() ? defaultMessage(snapshot.state())
|
||||||
: snapshot.message());
|
: snapshot.message());
|
||||||
String stateDescription = "任务状态:" + messageLabel.getText();
|
String stateDescription = "任务状态:" + messageLabel.getText()
|
||||||
|
+ (determinate ? ",进度 " + percentageLabel.getText() : "");
|
||||||
setAccessibleText(stateDescription);
|
setAccessibleText(stateDescription);
|
||||||
messageLabel.setAccessibleText(stateDescription);
|
messageLabel.setAccessibleText(stateDescription);
|
||||||
progressBar.setAccessibleText("任务进度:" + stateDescription);
|
progressBar.setAccessibleText("任务进度:" + stateDescription);
|
||||||
cancelButton.setVisible(cancellable
|
percentageLabel.setAccessibleText(determinate
|
||||||
&& snapshot.state() == OperationState.RUNNING);
|
? "任务进度:" + percentageLabel.getText()
|
||||||
cancelButton.setManaged(cancelButton.isVisible());
|
: "任务进度未知");
|
||||||
cancelButton.setDisable(snapshot.state() != OperationState.RUNNING);
|
if (cancelButton != null) {
|
||||||
|
cancelButton.setVisible(snapshot.state() == OperationState.RUNNING);
|
||||||
|
cancelButton.setManaged(cancelButton.isVisible());
|
||||||
|
cancelButton.setDisable(snapshot.state() != OperationState.RUNNING);
|
||||||
|
}
|
||||||
|
|
||||||
getStyleClass().removeAll(
|
getStyleClass().removeAll(
|
||||||
"operation-status-success", "operation-status-error");
|
"operation-status-success", "operation-status-error");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.workutils.system.ui;
|
package com.example.workutils.system.ui;
|
||||||
|
|
||||||
import com.example.workutils.system.ui.async.AsyncOperationController;
|
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.async.OperationStatusPane;
|
||||||
import com.example.workutils.system.ui.history.ExportHistoryPane;
|
import com.example.workutils.system.ui.history.ExportHistoryPane;
|
||||||
import com.example.workutils.system.ui.history.HistoryFileCommands;
|
import com.example.workutils.system.ui.history.HistoryFileCommands;
|
||||||
@@ -17,16 +18,22 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.FutureTask;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class SharedPaneAccessibilityTest {
|
class SharedPaneAccessibilityTest {
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
static void startJavaFx() throws Exception {
|
static void startJavaFx() throws Exception {
|
||||||
CountDownLatch started = new CountDownLatch(1);
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
Platform.startup(started::countDown);
|
try {
|
||||||
|
Platform.startup(started::countDown);
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
started.countDown();
|
||||||
|
}
|
||||||
started.await();
|
started.await();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +55,7 @@ class SharedPaneAccessibilityTest {
|
|||||||
runFx(() -> {
|
runFx(() -> {
|
||||||
AsyncOperationController<Void> controller =
|
AsyncOperationController<Void> controller =
|
||||||
new AsyncOperationController<>(Runnable::run, Runnable::run);
|
new AsyncOperationController<>(Runnable::run, Runnable::run);
|
||||||
OperationStatusPane pane = new OperationStatusPane(controller, 14, false);
|
OperationStatusPane pane = new OperationStatusPane(controller, false);
|
||||||
|
|
||||||
long buttons = pane.getChildren().stream()
|
long buttons = pane.getChildren().stream()
|
||||||
.filter(Button.class::isInstance)
|
.filter(Button.class::isInstance)
|
||||||
@@ -59,6 +66,42 @@ class SharedPaneAccessibilityTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void determinateStatusShowsPercentAndCancellableButtonRequestsCancellation()
|
||||||
|
throws Exception {
|
||||||
|
runFx(() -> {
|
||||||
|
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||||
|
List<Runnable> uiUpdates = new ArrayList<>();
|
||||||
|
AsyncOperationController<Void> controller =
|
||||||
|
new AsyncOperationController<>(pendingOperation::set, uiUpdates::add);
|
||||||
|
OperationStatusPane pane = new OperationStatusPane(controller, true);
|
||||||
|
|
||||||
|
List<Button> buttons = pane.getChildren().stream()
|
||||||
|
.filter(Button.class::isInstance)
|
||||||
|
.map(Button.class::cast)
|
||||||
|
.toList();
|
||||||
|
assertEquals(1, buttons.size());
|
||||||
|
Button cancelButton = buttons.get(0);
|
||||||
|
assertTrue(cancelButton.isFocusTraversable());
|
||||||
|
|
||||||
|
controller.start("导出中", context -> {
|
||||||
|
context.report("导出中", 0.42);
|
||||||
|
return null;
|
||||||
|
}, ignored -> { });
|
||||||
|
pendingOperation.get().run();
|
||||||
|
uiUpdates.get(0).run();
|
||||||
|
|
||||||
|
assertTrue(pane.getChildren().stream()
|
||||||
|
.filter(javafx.scene.control.Label.class::isInstance)
|
||||||
|
.map(javafx.scene.control.Label.class::cast)
|
||||||
|
.map(javafx.scene.control.Label::getText)
|
||||||
|
.anyMatch(text -> text.contains("42%")));
|
||||||
|
|
||||||
|
cancelButton.fire();
|
||||||
|
assertEquals(OperationState.CANCELLING, controller.snapshot().state());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void enterOpensSelectedHistoryResult() throws Exception {
|
void enterOpensSelectedHistoryResult() throws Exception {
|
||||||
runFx(() -> {
|
runFx(() -> {
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
package com.example.workutils.system.ui.async;
|
package com.example.workutils.system.ui.async;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ProgressIndicator;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
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.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class LoadingButtonTextTest {
|
class LoadingButtonTextTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startJavaFx() throws Exception {
|
||||||
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(started::countDown);
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
started.countDown();
|
||||||
|
}
|
||||||
|
started.await();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void idleAndTerminalStatesUseIdleText() {
|
void idleAndTerminalStatesUseIdleText() {
|
||||||
assertEquals("开始扫描",
|
assertEquals("开始扫描",
|
||||||
@@ -31,4 +57,58 @@ class LoadingButtonTextTest {
|
|||||||
new OperationSnapshot(OperationState.CANCELLING, "任意消息", 0.2),
|
new OperationSnapshot(OperationState.CANCELLING, "任意消息", 0.2),
|
||||||
"开始扫描"));
|
"开始扫描"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindingPreservesMeasuredIdleWidthAndUsesSharedBusyPresentation() throws Exception {
|
||||||
|
runFx(() -> {
|
||||||
|
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||||
|
AsyncOperationController<Void> controller =
|
||||||
|
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
|
||||||
|
Button button = new Button("开始扫描");
|
||||||
|
button.setAccessibleText("开始扫描文件");
|
||||||
|
StackPane root = new StackPane(button);
|
||||||
|
new Scene(root);
|
||||||
|
root.applyCss();
|
||||||
|
root.layout();
|
||||||
|
double idleWidth = button.prefWidth(-1);
|
||||||
|
|
||||||
|
LoadingButtonBinder.bind(button, controller, "开始扫描");
|
||||||
|
|
||||||
|
assertTrue(button.getMinWidth() >= idleWidth,
|
||||||
|
"binding must preserve the measured idle width");
|
||||||
|
|
||||||
|
controller.start("导出中", context -> null, ignored -> { });
|
||||||
|
|
||||||
|
assertEquals("导出中…", button.getText());
|
||||||
|
assertEquals(button.getText(), button.getAccessibleText());
|
||||||
|
assertTrue(button.getMinWidth() >= idleWidth);
|
||||||
|
assertTrue(button.getPseudoClassStates().contains(
|
||||||
|
PseudoClass.getPseudoClass("running")));
|
||||||
|
Node graphic = button.getGraphic();
|
||||||
|
ProgressIndicator spinner = assertInstanceOf(ProgressIndicator.class, graphic);
|
||||||
|
assertEquals(14, spinner.getPrefWidth());
|
||||||
|
assertEquals(14, spinner.getPrefHeight());
|
||||||
|
|
||||||
|
pendingOperation.get().run();
|
||||||
|
|
||||||
|
assertEquals("开始扫描", button.getText());
|
||||||
|
assertEquals("开始扫描文件", button.getAccessibleText());
|
||||||
|
assertFalse(button.getPseudoClassStates().contains(
|
||||||
|
PseudoClass.getPseudoClass("running")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runFx(ThrowingRunnable action) throws Exception {
|
||||||
|
FutureTask<Void> task = new FutureTask<>(() -> {
|
||||||
|
action.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
Platform.runLater(task);
|
||||||
|
task.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user