diff --git a/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java index 874d204..5f0b241 100644 --- a/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java +++ b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java @@ -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 snapshotListener; diff --git a/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java b/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java index 17e64ed..6e2547d 100644 --- a/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java +++ b/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java @@ -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 table = new TableView<>(); @@ -46,11 +55,30 @@ public final class ExportHistoryPane extends VBox { ObservableList items, HistoryFileCommands commands, int fontSize) { + this(queryColumnTitle, countColumnTitle, items, commands, fontSize, + AppExecutors.io(), Platform::runLater, ACTION_BUSY_MINIMUM); + } + + ExportHistoryPane( + String queryColumnTitle, + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize, + Executor actionExecutor, + Consumer 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 actionColumn = actionColumn(commands, fontSize); + TableColumn actionColumn = actionColumn( + commands, fontSize, actionExecutor, uiDispatcher, minimumBusyDuration); TableColumn indexColumn = textColumn("序号", HistoryItem::index); TableColumn queryColumn = textColumn(queryColumnTitle, HistoryItem::query); TableColumn fileColumn = fileColumn(); @@ -130,7 +159,11 @@ public final class ExportHistoryPane extends VBox { } private TableColumn actionColumn( - HistoryFileCommands commands, int fontSize) { + HistoryFileCommands commands, + int fontSize, + Executor actionExecutor, + Consumer uiDispatcher, + Duration minimumBusyDuration) { TableColumn 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 openController = + new AsyncOperationController<>(actionExecutor, uiDispatcher); + private final AsyncOperationController 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 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 -> { }); + } } diff --git a/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java index a2807bf..01974b0 100644 --- a/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java +++ b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java @@ -102,6 +102,32 @@ class LoadingButtonTextTest { }); } + @Test + void compactBindingShowsOnlySpinnerWhilePreservingAccessibleProgress() throws Exception { + runFx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + AsyncOperationController 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(() -> { diff --git a/src/test/java/com/example/workutils/system/ui/history/ExportHistoryPaneLoadingTest.java b/src/test/java/com/example/workutils/system/ui/history/ExportHistoryPaneLoadingTest.java new file mode 100644 index 0000000..1ed4737 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/history/ExportHistoryPaneLoadingTest.java @@ -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 background = new ArrayDeque<>(); + List 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 background = new ArrayDeque<>(); + List 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(); + } +}