修复异步控件测量与监听生命周期
This commit is contained in:
@@ -2,9 +2,13 @@ package com.example.workutils.system.ui.async;
|
|||||||
|
|
||||||
import javafx.beans.value.ChangeListener;
|
import javafx.beans.value.ChangeListener;
|
||||||
import javafx.css.PseudoClass;
|
import javafx.css.PseudoClass;
|
||||||
|
import javafx.geometry.Bounds;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
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;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -12,6 +16,7 @@ public final class LoadingButtonBinder {
|
|||||||
|
|
||||||
private static final double SPINNER_SIZE = 14;
|
private static final double SPINNER_SIZE = 14;
|
||||||
private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running");
|
private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running");
|
||||||
|
private static final Object BINDING_KEY = new Object();
|
||||||
|
|
||||||
private LoadingButtonBinder() {
|
private LoadingButtonBinder() {
|
||||||
}
|
}
|
||||||
@@ -20,18 +25,13 @@ public final class LoadingButtonBinder {
|
|||||||
String idleText) {
|
String idleText) {
|
||||||
Objects.requireNonNull(button, "button");
|
Objects.requireNonNull(button, "button");
|
||||||
Objects.requireNonNull(controller, "controller");
|
Objects.requireNonNull(controller, "controller");
|
||||||
Node idleGraphic = button.getGraphic();
|
Objects.requireNonNull(idleText, "idleText");
|
||||||
String idleAccessibleText = button.getAccessibleText();
|
ButtonBinding binding = (ButtonBinding) button.getProperties().get(BINDING_KEY);
|
||||||
preserveIdleWidth(button);
|
if (binding == null) {
|
||||||
ProgressIndicator spinner = createSpinner();
|
binding = new ButtonBinding(button, idleText);
|
||||||
|
button.getProperties().put(BINDING_KEY, binding);
|
||||||
ChangeListener<OperationSnapshot> listener =
|
}
|
||||||
(observable, oldValue, snapshot) ->
|
binding.bind(controller);
|
||||||
apply(button, idleGraphic, idleAccessibleText, spinner,
|
|
||||||
snapshot, idleText);
|
|
||||||
controller.snapshotProperty().addListener(listener);
|
|
||||||
apply(button, idleGraphic, idleAccessibleText, spinner,
|
|
||||||
controller.snapshot(), idleText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String textFor(OperationSnapshot snapshot, String idleText) {
|
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,
|
private static void apply(ButtonBinding binding, OperationSnapshot snapshot) {
|
||||||
ProgressIndicator spinner, OperationSnapshot snapshot,
|
Button button = binding.button;
|
||||||
String idleText) {
|
|
||||||
boolean busy = snapshot.state() == OperationState.RUNNING
|
boolean busy = snapshot.state() == OperationState.RUNNING
|
||||||
|| snapshot.state() == OperationState.CANCELLING;
|
|| snapshot.state() == OperationState.CANCELLING;
|
||||||
String visibleText = textFor(snapshot, idleText);
|
String visibleText = textFor(snapshot, binding.idleText);
|
||||||
button.setText(visibleText);
|
button.setText(visibleText);
|
||||||
button.setAccessibleText(busy ? visibleText : idleAccessibleText);
|
button.setAccessibleText(busy ? visibleText : binding.idleAccessibleText);
|
||||||
button.setDisable(busy);
|
button.setDisable(busy);
|
||||||
button.setGraphic(busy ? spinner : idleGraphic);
|
button.setGraphic(busy ? binding.spinner : binding.idleGraphic);
|
||||||
button.pseudoClassStateChanged(RUNNING, busy);
|
button.pseudoClassStateChanged(RUNNING, busy);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void preserveIdleWidth(Button button) {
|
private static void preserveIdleWidth(ButtonBinding binding) {
|
||||||
button.applyCss();
|
Button button = binding.button;
|
||||||
double idleWidth = button.prefWidth(-1);
|
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();
|
double configuredMinWidth = button.getMinWidth();
|
||||||
if (configuredMinWidth >= 0) {
|
if (configuredMinWidth >= 0) {
|
||||||
idleWidth = Math.max(idleWidth, configuredMinWidth);
|
idleWidth = Math.max(idleWidth, configuredMinWidth);
|
||||||
}
|
}
|
||||||
if (Double.isFinite(idleWidth) && idleWidth > 0) {
|
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) {
|
private static String trimEllipsis(String message) {
|
||||||
String trimmed = message.trim();
|
String trimmed = message.trim();
|
||||||
while (trimmed.endsWith("…")) {
|
while (trimmed.endsWith("…")) {
|
||||||
@@ -86,4 +107,44 @@ public final class LoadingButtonBinder {
|
|||||||
spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE);
|
spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE);
|
||||||
return spinner;
|
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<OperationSnapshot> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.workutils.system.ui.async;
|
package com.example.workutils.system.ui.async;
|
||||||
|
|
||||||
import com.example.workutils.system.ui.Theme;
|
import com.example.workutils.system.ui.Theme;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
@@ -16,6 +17,9 @@ public final class OperationStatusPane extends HBox {
|
|||||||
private final Label messageLabel = new Label("就绪");
|
private final Label messageLabel = new Label("就绪");
|
||||||
private final Label percentageLabel = new Label();
|
private final Label percentageLabel = new Label();
|
||||||
private final Button cancelButton;
|
private final Button cancelButton;
|
||||||
|
private final AsyncOperationController<?> controller;
|
||||||
|
private final ChangeListener<OperationSnapshot> snapshotListener;
|
||||||
|
private boolean disposed;
|
||||||
|
|
||||||
/** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */
|
/** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@@ -33,6 +37,7 @@ public final class OperationStatusPane extends HBox {
|
|||||||
public OperationStatusPane(AsyncOperationController<?> controller, boolean cancellable) {
|
public OperationStatusPane(AsyncOperationController<?> controller, boolean cancellable) {
|
||||||
super(Theme.S2);
|
super(Theme.S2);
|
||||||
Objects.requireNonNull(controller, "controller");
|
Objects.requireNonNull(controller, "controller");
|
||||||
|
this.controller = controller;
|
||||||
setAlignment(Pos.CENTER_LEFT);
|
setAlignment(Pos.CENTER_LEFT);
|
||||||
getStyleClass().add("operation-status");
|
getStyleClass().add("operation-status");
|
||||||
|
|
||||||
@@ -56,11 +61,26 @@ public final class OperationStatusPane extends HBox {
|
|||||||
} else {
|
} else {
|
||||||
cancelButton = null;
|
cancelButton = null;
|
||||||
}
|
}
|
||||||
controller.snapshotProperty().addListener(
|
snapshotListener = (observable, oldValue, value) -> render(value);
|
||||||
(observable, oldValue, value) -> render(value));
|
controller.snapshotProperty().addListener(snapshotListener);
|
||||||
render(controller.snapshot());
|
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) {
|
private void render(OperationSnapshot snapshot) {
|
||||||
progressBar.setProgress(snapshot.progress());
|
progressBar.setProgress(snapshot.progress());
|
||||||
boolean active = snapshot.state() == OperationState.RUNNING
|
boolean active = snapshot.state() == OperationState.RUNNING
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import javafx.collections.FXCollections;
|
|||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.input.KeyCode;
|
import javafx.scene.input.KeyCode;
|
||||||
import javafx.scene.input.KeyEvent;
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class SharedPaneAccessibilityTest {
|
class SharedPaneAccessibilityTest {
|
||||||
@@ -75,6 +77,8 @@ class SharedPaneAccessibilityTest {
|
|||||||
AsyncOperationController<Void> controller =
|
AsyncOperationController<Void> controller =
|
||||||
new AsyncOperationController<>(pendingOperation::set, uiUpdates::add);
|
new AsyncOperationController<>(pendingOperation::set, uiUpdates::add);
|
||||||
OperationStatusPane pane = new OperationStatusPane(controller, true);
|
OperationStatusPane pane = new OperationStatusPane(controller, true);
|
||||||
|
assertNull(pane.getScene(),
|
||||||
|
"an initially unattached pane must still observe its controller");
|
||||||
|
|
||||||
List<Button> buttons = pane.getChildren().stream()
|
List<Button> buttons = pane.getChildren().stream()
|
||||||
.filter(Button.class::isInstance)
|
.filter(Button.class::isInstance)
|
||||||
@@ -102,6 +106,27 @@ class SharedPaneAccessibilityTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disposedStatusPaneStopsRenderingControllerUpdates() throws Exception {
|
||||||
|
runFx(() -> {
|
||||||
|
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||||
|
AsyncOperationController<Void> controller =
|
||||||
|
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
|
||||||
|
OperationStatusPane pane = new OperationStatusPane(controller, false);
|
||||||
|
StackPane parent = new StackPane(pane);
|
||||||
|
String idleDescription = pane.getAccessibleText();
|
||||||
|
|
||||||
|
parent.getChildren().remove(pane);
|
||||||
|
pane.dispose();
|
||||||
|
controller.start("不应显示", context -> null, ignored -> { });
|
||||||
|
|
||||||
|
assertEquals(idleDescription, pane.getAccessibleText());
|
||||||
|
|
||||||
|
pendingOperation.get().run();
|
||||||
|
assertEquals(idleDescription, pane.getAccessibleText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void enterOpensSelectedHistoryResult() throws Exception {
|
void enterOpensSelectedHistoryResult() throws Exception {
|
||||||
runFx(() -> {
|
runFx(() -> {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import javafx.scene.Scene;
|
|||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.ProgressIndicator;
|
import javafx.scene.control.ProgressIndicator;
|
||||||
import javafx.scene.layout.StackPane;
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ 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.assertInstanceOf;
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class LoadingButtonTextTest {
|
class LoadingButtonTextTest {
|
||||||
@@ -98,6 +102,69 @@ class LoadingButtonTextTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindingWithoutScenePreservesIdleContentWidthAcrossStateChanges() throws Exception {
|
||||||
|
runFx(() -> {
|
||||||
|
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||||
|
AsyncOperationController<Void> controller =
|
||||||
|
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
|
||||||
|
Button button = new Button("开始扫描较长内容");
|
||||||
|
Text idleContent = new Text(button.getText());
|
||||||
|
idleContent.setFont(button.getFont());
|
||||||
|
double idleContentWidth = idleContent.getLayoutBounds().getWidth();
|
||||||
|
assertNull(button.getScene());
|
||||||
|
assertNull(button.getSkin());
|
||||||
|
|
||||||
|
LoadingButtonBinder.bind(button, controller, button.getText());
|
||||||
|
|
||||||
|
double preservedWidth = button.getMinWidth();
|
||||||
|
assertTrue(preservedWidth >= idleContentWidth,
|
||||||
|
"an unskinned button must preserve its idle content width");
|
||||||
|
|
||||||
|
controller.start("短", context -> null, ignored -> { });
|
||||||
|
assertEquals(preservedWidth, button.getMinWidth());
|
||||||
|
|
||||||
|
pendingOperation.get().run();
|
||||||
|
assertEquals(preservedWidth, button.getMinWidth());
|
||||||
|
assertEquals("开始扫描较长内容", button.getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rebindingReplacesControllerWithoutLosingOriginalIdleState() throws Exception {
|
||||||
|
runFx(() -> {
|
||||||
|
AtomicReference<Runnable> firstPending = new AtomicReference<>();
|
||||||
|
AtomicReference<Runnable> secondPending = new AtomicReference<>();
|
||||||
|
AsyncOperationController<Void> firstController =
|
||||||
|
new AsyncOperationController<>(firstPending::set, Runnable::run);
|
||||||
|
AsyncOperationController<Void> secondController =
|
||||||
|
new AsyncOperationController<>(secondPending::set, Runnable::run);
|
||||||
|
Rectangle originalGraphic = new Rectangle(12, 12);
|
||||||
|
Button button = new Button("开始扫描", originalGraphic);
|
||||||
|
button.setAccessibleText("扫描文件");
|
||||||
|
|
||||||
|
LoadingButtonBinder.bind(button, firstController, "开始扫描");
|
||||||
|
firstController.start("旧任务", context -> null, ignored -> { });
|
||||||
|
|
||||||
|
LoadingButtonBinder.bind(button, secondController, "错误的空闲文案");
|
||||||
|
secondController.start("新任务", context -> null, ignored -> { });
|
||||||
|
firstPending.get().run();
|
||||||
|
|
||||||
|
assertEquals("新任务…", button.getText(),
|
||||||
|
"the old controller must no longer drive the button");
|
||||||
|
assertTrue(button.getPseudoClassStates().contains(
|
||||||
|
PseudoClass.getPseudoClass("running")));
|
||||||
|
|
||||||
|
secondPending.get().run();
|
||||||
|
|
||||||
|
assertEquals("开始扫描", button.getText());
|
||||||
|
assertSame(originalGraphic, button.getGraphic());
|
||||||
|
assertEquals("扫描文件", button.getAccessibleText());
|
||||||
|
assertFalse(button.getPseudoClassStates().contains(
|
||||||
|
PseudoClass.getPseudoClass("running")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void runFx(ThrowingRunnable action) throws Exception {
|
private static void runFx(ThrowingRunnable action) throws Exception {
|
||||||
FutureTask<Void> task = new FutureTask<>(() -> {
|
FutureTask<Void> task = new FutureTask<>(() -> {
|
||||||
action.run();
|
action.run();
|
||||||
|
|||||||
Reference in New Issue
Block a user