Add loading state to history action buttons

This commit is contained in:
张家训
2026-08-02 16:17:50 +08:00
parent 5bc4ef194b
commit b84a07c249
4 changed files with 231 additions and 6 deletions
@@ -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(() -> {
@@ -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();
}
}