Add loading state to history action buttons
This commit is contained in:
@@ -23,6 +23,17 @@ public final class LoadingButtonBinder {
|
||||
|
||||
public static void bind(Button button, AsyncOperationController<?> controller,
|
||||
String idleText) {
|
||||
bind(button, controller, idleText, null);
|
||||
}
|
||||
|
||||
public static void bindCompact(Button button, AsyncOperationController<?> controller,
|
||||
String idleText, String busyAccessibleText) {
|
||||
Objects.requireNonNull(busyAccessibleText, "busyAccessibleText");
|
||||
bind(button, controller, idleText, busyAccessibleText);
|
||||
}
|
||||
|
||||
private static void bind(Button button, AsyncOperationController<?> controller,
|
||||
String idleText, String busyAccessibleText) {
|
||||
Objects.requireNonNull(button, "button");
|
||||
Objects.requireNonNull(controller, "controller");
|
||||
Objects.requireNonNull(idleText, "idleText");
|
||||
@@ -31,6 +42,7 @@ public final class LoadingButtonBinder {
|
||||
binding = new ButtonBinding(button, idleText);
|
||||
button.getProperties().put(BINDING_KEY, binding);
|
||||
}
|
||||
binding.busyAccessibleText = busyAccessibleText;
|
||||
binding.bind(controller);
|
||||
}
|
||||
|
||||
@@ -49,9 +61,14 @@ public final class LoadingButtonBinder {
|
||||
Button button = binding.button;
|
||||
boolean busy = snapshot.state() == OperationState.RUNNING
|
||||
|| snapshot.state() == OperationState.CANCELLING;
|
||||
String visibleText = textFor(snapshot, binding.idleText);
|
||||
boolean compact = binding.busyAccessibleText != null;
|
||||
String visibleText = busy && compact
|
||||
? ""
|
||||
: textFor(snapshot, binding.idleText);
|
||||
button.setText(visibleText);
|
||||
button.setAccessibleText(busy ? visibleText : binding.idleAccessibleText);
|
||||
button.setAccessibleText(busy
|
||||
? compact ? binding.busyAccessibleText : visibleText
|
||||
: binding.idleAccessibleText);
|
||||
button.setDisable(busy);
|
||||
button.setGraphic(busy ? binding.spinner : binding.idleGraphic);
|
||||
button.pseudoClassStateChanged(RUNNING, busy);
|
||||
@@ -115,6 +132,7 @@ public final class LoadingButtonBinder {
|
||||
private final Node idleGraphic;
|
||||
private final String idleAccessibleText;
|
||||
private final ProgressIndicator spinner = createSpinner();
|
||||
private String busyAccessibleText;
|
||||
private AsyncOperationController<?> controller;
|
||||
private ChangeListener<OperationSnapshot> snapshotListener;
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.example.workutils.system.ui.history;
|
||||
import com.example.workutils.system.ui.EmptyStatePane;
|
||||
import com.example.workutils.system.ui.StyledButton;
|
||||
import com.example.workutils.system.ui.Theme;
|
||||
import com.example.workutils.system.support.AppExecutors;
|
||||
import com.example.workutils.system.ui.async.AsyncOperationController;
|
||||
import com.example.workutils.system.ui.async.LoadingButtonBinder;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyStringWrapper;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -18,7 +22,11 @@ import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class ExportHistoryPane extends VBox {
|
||||
@@ -29,6 +37,7 @@ public final class ExportHistoryPane extends VBox {
|
||||
private static final double FILE_MIN_WIDTH = 220;
|
||||
private static final double COUNT_WIDTH = 88;
|
||||
private static final double TIME_WIDTH = 180;
|
||||
private static final Duration ACTION_BUSY_MINIMUM = Duration.ofMillis(240);
|
||||
|
||||
private final TableView<HistoryItem> table = new TableView<>();
|
||||
|
||||
@@ -46,11 +55,30 @@ public final class ExportHistoryPane extends VBox {
|
||||
ObservableList<HistoryItem> items,
|
||||
HistoryFileCommands commands,
|
||||
int fontSize) {
|
||||
this(queryColumnTitle, countColumnTitle, items, commands, fontSize,
|
||||
AppExecutors.io(), Platform::runLater, ACTION_BUSY_MINIMUM);
|
||||
}
|
||||
|
||||
ExportHistoryPane(
|
||||
String queryColumnTitle,
|
||||
String countColumnTitle,
|
||||
ObservableList<HistoryItem> items,
|
||||
HistoryFileCommands commands,
|
||||
int fontSize,
|
||||
Executor actionExecutor,
|
||||
Consumer<Runnable> uiDispatcher,
|
||||
Duration minimumBusyDuration) {
|
||||
super(Theme.S2);
|
||||
Objects.requireNonNull(queryColumnTitle, "queryColumnTitle");
|
||||
Objects.requireNonNull(countColumnTitle, "countColumnTitle");
|
||||
Objects.requireNonNull(items, "items");
|
||||
Objects.requireNonNull(commands, "commands");
|
||||
Objects.requireNonNull(actionExecutor, "actionExecutor");
|
||||
Objects.requireNonNull(uiDispatcher, "uiDispatcher");
|
||||
Objects.requireNonNull(minimumBusyDuration, "minimumBusyDuration");
|
||||
if (minimumBusyDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("minimumBusyDuration must not be negative");
|
||||
}
|
||||
|
||||
table.setItems(items);
|
||||
table.setAccessibleText("导出历史记录");
|
||||
@@ -71,7 +99,8 @@ public final class ExportHistoryPane extends VBox {
|
||||
null, fontSize));
|
||||
table.getStyleClass().add("export-history-table");
|
||||
|
||||
TableColumn<HistoryItem, Void> actionColumn = actionColumn(commands, fontSize);
|
||||
TableColumn<HistoryItem, Void> actionColumn = actionColumn(
|
||||
commands, fontSize, actionExecutor, uiDispatcher, minimumBusyDuration);
|
||||
TableColumn<HistoryItem, String> indexColumn = textColumn("序号", HistoryItem::index);
|
||||
TableColumn<HistoryItem, String> queryColumn = textColumn(queryColumnTitle, HistoryItem::query);
|
||||
TableColumn<HistoryItem, String> fileColumn = fileColumn();
|
||||
@@ -130,7 +159,11 @@ public final class ExportHistoryPane extends VBox {
|
||||
}
|
||||
|
||||
private TableColumn<HistoryItem, Void> actionColumn(
|
||||
HistoryFileCommands commands, int fontSize) {
|
||||
HistoryFileCommands commands,
|
||||
int fontSize,
|
||||
Executor actionExecutor,
|
||||
Consumer<Runnable> uiDispatcher,
|
||||
Duration minimumBusyDuration) {
|
||||
TableColumn<HistoryItem, Void> column = new TableColumn<>("操作");
|
||||
column.setCellFactory(ignored -> new TableCell<>() {
|
||||
private final Button open = StyledButton.small(
|
||||
@@ -139,6 +172,10 @@ public final class ExportHistoryPane extends VBox {
|
||||
"目录", Theme.base(fontSize - 1));
|
||||
private final HBox actions = new HBox(4, open, folder);
|
||||
private final ContextMenu menu = new ContextMenu();
|
||||
private final AsyncOperationController<Void> openController =
|
||||
new AsyncOperationController<>(actionExecutor, uiDispatcher);
|
||||
private final AsyncOperationController<Void> folderController =
|
||||
new AsyncOperationController<>(actionExecutor, uiDispatcher);
|
||||
private HistoryItem row;
|
||||
|
||||
{
|
||||
@@ -146,6 +183,10 @@ public final class ExportHistoryPane extends VBox {
|
||||
open.setAccessibleText("打开生成文件");
|
||||
folder.setAccessibleText("打开生成文件所在目录");
|
||||
folder.setTooltip(new Tooltip("打开文件所在目录"));
|
||||
LoadingButtonBinder.bindCompact(
|
||||
open, openController, "打开", "正在打开生成文件");
|
||||
LoadingButtonBinder.bindCompact(
|
||||
folder, folderController, "目录", "正在打开生成文件所在目录");
|
||||
MenuItem edit = new MenuItem("用系统编辑器打开");
|
||||
MenuItem copy = new MenuItem("复制文件路径");
|
||||
edit.setOnAction(event -> commands.openInEditor(row.filePath()));
|
||||
@@ -153,8 +194,16 @@ public final class ExportHistoryPane extends VBox {
|
||||
menu.getItems().addAll(edit, copy);
|
||||
actions.setOnContextMenuRequested(event ->
|
||||
menu.show(actions, event.getScreenX(), event.getScreenY()));
|
||||
open.setOnAction(event -> commands.openFile(row.filePath()));
|
||||
folder.setOnAction(event -> commands.openFolder(row.filePath()));
|
||||
open.setOnAction(event -> {
|
||||
String path = row.filePath();
|
||||
runAction(openController, "正在打开", minimumBusyDuration,
|
||||
() -> commands.openFile(path));
|
||||
});
|
||||
folder.setOnAction(event -> {
|
||||
String path = row.filePath();
|
||||
runAction(folderController, "正在打开目录", minimumBusyDuration,
|
||||
() -> commands.openFolder(path));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -167,4 +216,24 @@ public final class ExportHistoryPane extends VBox {
|
||||
});
|
||||
return column;
|
||||
}
|
||||
|
||||
private static void runAction(
|
||||
AsyncOperationController<Void> controller,
|
||||
String message,
|
||||
Duration minimumBusyDuration,
|
||||
Runnable action) {
|
||||
controller.start(message, context -> {
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
action.run();
|
||||
} finally {
|
||||
long remainingNanos = minimumBusyDuration.toNanos()
|
||||
- (System.nanoTime() - startedAt);
|
||||
if (remainingNanos > 0) {
|
||||
TimeUnit.NANOSECONDS.sleep(remainingNanos);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, ignored -> { });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,32 @@ class LoadingButtonTextTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void compactBindingShowsOnlySpinnerWhilePreservingAccessibleProgress() throws Exception {
|
||||
runFx(() -> {
|
||||
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AsyncOperationController<Void> controller =
|
||||
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
|
||||
Button button = new Button("打开");
|
||||
button.setAccessibleText("打开生成文件");
|
||||
|
||||
LoadingButtonBinder.bindCompact(
|
||||
button, controller, "打开", "正在打开生成文件");
|
||||
controller.start("正在打开", context -> null, ignored -> { });
|
||||
|
||||
assertEquals("", button.getText());
|
||||
assertEquals("正在打开生成文件", button.getAccessibleText());
|
||||
assertTrue(button.isDisable());
|
||||
assertInstanceOf(ProgressIndicator.class, button.getGraphic());
|
||||
|
||||
pendingOperation.get().run();
|
||||
|
||||
assertEquals("打开", button.getText());
|
||||
assertEquals("打开生成文件", button.getAccessibleText());
|
||||
assertFalse(button.isDisable());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingWithoutScenePreservesIdleContentWidthAcrossStateChanges() throws Exception {
|
||||
runFx(() -> {
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.example.workutils.system.ui.history;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
|
||||
import static com.example.workutils.system.ui.FxTestSupport.fx;
|
||||
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 ExportHistoryPaneLoadingTest {
|
||||
|
||||
@Test
|
||||
void openAndFolderButtonsRunInBackgroundWithCompactLoadingState() throws Exception {
|
||||
fx(() -> {
|
||||
Queue<Runnable> background = new ArrayDeque<>();
|
||||
List<String> calls = new ArrayList<>();
|
||||
HistoryFileCommands commands = new HistoryFileCommands(
|
||||
path -> calls.add("open:" + path),
|
||||
path -> { },
|
||||
path -> calls.add("folder:" + path),
|
||||
path -> { });
|
||||
HistoryItem item = new HistoryItem(
|
||||
"1", "HTML", "D:/exports/api-document.html", "1", "2026-08-02 16:00:00");
|
||||
ExportHistoryPane pane = new ExportHistoryPane(
|
||||
"导出格式", "文件数", FXCollections.observableArrayList(item), commands, 14,
|
||||
background::add, Runnable::run, Duration.ZERO);
|
||||
StackPane root = new StackPane(pane);
|
||||
new Scene(root, 900, 420);
|
||||
root.applyCss();
|
||||
root.layout();
|
||||
|
||||
Button open = button(root, "打开");
|
||||
open.fire();
|
||||
|
||||
assertTrue(open.isDisable());
|
||||
assertEquals("", open.getText());
|
||||
assertEquals("正在打开生成文件", open.getAccessibleText());
|
||||
assertInstanceOf(ProgressIndicator.class, open.getGraphic());
|
||||
assertTrue(calls.isEmpty(), "file opening must not run on the JavaFX thread");
|
||||
|
||||
background.remove().run();
|
||||
|
||||
assertEquals(List.of("open:D:/exports/api-document.html"), calls);
|
||||
assertFalse(open.isDisable());
|
||||
assertEquals("打开", open.getText());
|
||||
assertEquals("打开生成文件", open.getAccessibleText());
|
||||
|
||||
Button folder = button(root, "目录");
|
||||
folder.fire();
|
||||
assertTrue(folder.isDisable());
|
||||
assertInstanceOf(ProgressIndicator.class, folder.getGraphic());
|
||||
|
||||
background.remove().run();
|
||||
|
||||
assertEquals(List.of(
|
||||
"open:D:/exports/api-document.html",
|
||||
"folder:D:/exports/api-document.html"), calls);
|
||||
assertFalse(folder.isDisable());
|
||||
assertEquals("目录", folder.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesTheClickedPathBeforeTheTableCellIsReused() throws Exception {
|
||||
fx(() -> {
|
||||
Queue<Runnable> background = new ArrayDeque<>();
|
||||
List<String> opened = new ArrayList<>();
|
||||
HistoryItem clicked = new HistoryItem(
|
||||
"1", "HTML", "D:/exports/first.html", "1", "2026-08-02 16:00:00");
|
||||
ExportHistoryPane pane = new ExportHistoryPane(
|
||||
"导出格式", "文件数", FXCollections.observableArrayList(clicked),
|
||||
new HistoryFileCommands(
|
||||
opened::add, path -> { }, path -> { }, path -> { }),
|
||||
14, background::add, Runnable::run, Duration.ZERO);
|
||||
StackPane root = new StackPane(pane);
|
||||
new Scene(root, 900, 420);
|
||||
root.applyCss();
|
||||
root.layout();
|
||||
|
||||
button(root, "打开").fire();
|
||||
pane.table().getItems().set(0, new HistoryItem(
|
||||
"2", "HTML", "D:/exports/reused.html", "1", "2026-08-02 16:01:00"));
|
||||
pane.table().refresh();
|
||||
root.layout();
|
||||
|
||||
background.remove().run();
|
||||
|
||||
assertEquals(List.of("D:/exports/first.html"), opened);
|
||||
});
|
||||
}
|
||||
|
||||
private static Button button(StackPane root, String text) {
|
||||
return root.lookupAll(".button").stream()
|
||||
.filter(Button.class::isInstance)
|
||||
.map(Button.class::cast)
|
||||
.filter(button -> text.equals(button.getText()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user