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 -> { });
}
}