From 09cd8befff4c8b9110ae5ad629cecb39fc2624cc Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Sun, 7 Dec 2025 17:48:20 -0500 Subject: Add BatchTaskProgressPanel Adds BatchTaskProgressPanel, a augmented/specialized version of MultiTaskProgressPanel that uses CollapsiblePanel to allow tracking of batches of related tasks using SwingWorkers --- .../gui/panels/BatchTaskProgressPanelDemo.java | 62 +++ .../utils/gui/panels/BatchTaskProgressPanel.java | 517 +++++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 base/src/examples/java/bjc/utils/examples/gui/panels/BatchTaskProgressPanelDemo.java create mode 100644 base/src/main/java/bjc/utils/gui/panels/BatchTaskProgressPanel.java diff --git a/base/src/examples/java/bjc/utils/examples/gui/panels/BatchTaskProgressPanelDemo.java b/base/src/examples/java/bjc/utils/examples/gui/panels/BatchTaskProgressPanelDemo.java new file mode 100644 index 0000000..9d2312c --- /dev/null +++ b/base/src/examples/java/bjc/utils/examples/gui/panels/BatchTaskProgressPanelDemo.java @@ -0,0 +1,62 @@ +package bjc.utils.examples.gui.panels; + +import java.awt.BorderLayout; + +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; + +import bjc.utils.gui.panels.BatchTaskProgressPanel; +import bjc.utils.gui.panels.BatchTaskProgressPanel.BatchHandle; + +/** + * Demo for {@link BatchTaskProgressPanel} + */ +public class BatchTaskProgressPanelDemo { + /** + * Main method + * @param args Unused CLI args + */ + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + JFrame frame = new JFrame("BatchTaskProgressPanel Demo"); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setLayout(new BorderLayout()); + + BatchTaskProgressPanel panel = new BatchTaskProgressPanel(); + frame.add(panel, BorderLayout.CENTER); + + JButton addBatch = new JButton("Start fake batch"); + addBatch.addActionListener(e -> { + // Simulate a batch with 3 SwingWorkers + BatchHandle batch = panel.startBatch("Save batch at " + System.currentTimeMillis()); + + for (int i = 1; i <= 3; i++) { + final int idx = i; + SwingWorker worker = new SwingWorker<>() { + @Override + protected Void doInBackground() { + for (int p = 0; p <= 100; p++) { + if (isCancelled()) break; + setProgress(p); + try { + Thread.sleep(30 + idx * 10L); + } catch (InterruptedException ignored) { + } + } + return null; + } + }; + batch.monitorSwingWorker(worker, "Collection " + idx, true); + worker.execute(); + } + }); + + frame.add(addBatch, BorderLayout.SOUTH); + frame.setSize(600, 400); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + }); + } +} diff --git a/base/src/main/java/bjc/utils/gui/panels/BatchTaskProgressPanel.java b/base/src/main/java/bjc/utils/gui/panels/BatchTaskProgressPanel.java new file mode 100644 index 0000000..8d091af --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/panels/BatchTaskProgressPanel.java @@ -0,0 +1,517 @@ +package bjc.utils.gui.panels; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A Swing panel that groups multiple background tasks into collapsible "batches" + * using CollapsiblePanel, similar to a download manager. + * + * Each batch: + * - Appears as a CollapsiblePanel with a title. + * - Contains a vertical list of task rows. + * + * Each task: + * - Has a description label. + * - A progress bar (0..max). + * - A small note/status line. + * - Optional Cancel button. + * + * You typically: + * + * BatchTaskProgressPanel progressPanel = new BatchTaskProgressPanel(); + * + * BatchTaskProgressPanel.BatchHandle batch = + * progressPanel.startBatch("Saving Batch 1"); + * + * SwingWorker w1 = ...; + * batch.monitorSwingWorker(w1, "Collection A", true); + * w1.execute(); + * + * SwingWorker w2 = ...; + * batch.monitorSwingWorker(w2, "Collection B", true); + * w2.execute(); + */ +public class BatchTaskProgressPanel extends JPanel { + private static final long serialVersionUID = 758145786594145134L; + + private final JPanel batchesPanel; + private final JScrollPane scrollPane; + private final JButton clearFinishedButton; + + // UI for each batch + private static class BatchUI { + final CollapsiblePanel collapsible; + final JPanel taskListPanel; // BoxLayout.Y_AXIS + + BatchUI(CollapsiblePanel collapsible, JPanel taskListPanel) { + this.collapsible = collapsible; + this.taskListPanel = taskListPanel; + } + } + + private final Map batches = new LinkedHashMap<>(); + private final Map taskPanels = new LinkedHashMap<>(); + + /** + * Create a new BatchTaskProgressPanel + */ + public BatchTaskProgressPanel() { + super(new BorderLayout()); + + batchesPanel = new JPanel(); + batchesPanel.setLayout(new BoxLayout(batchesPanel, BoxLayout.Y_AXIS)); + + scrollPane = new JScrollPane(batchesPanel); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + + clearFinishedButton = new JButton(new AbstractAction("Clear finished") { + @Override + public void actionPerformed(ActionEvent e) { + clearFinishedTasksAndEmptyBatches(); + } + }); + + JPanel header = new JPanel(new BorderLayout()); + header.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + header.add(new JLabel("Background tasks"), BorderLayout.WEST); + header.add(clearFinishedButton, BorderLayout.EAST); + + add(header, BorderLayout.NORTH); + add(scrollPane, BorderLayout.CENTER); + } + + /** + * Start a new batch section with a given title. + * + * @param batchTitle The title for the batch + * @return The new batch + */ + public BatchHandle startBatch(String batchTitle) { + BatchHandle handle = new BatchHandle(this); + runOnEdt(() -> { + CollapsiblePanel collapsible = new CollapsiblePanel(batchTitle); + + JPanel taskListPanel = new JPanel(); + taskListPanel.setLayout(new BoxLayout(taskListPanel, BoxLayout.Y_AXIS)); + + collapsible.add(taskListPanel); + + BatchUI batchUI = new BatchUI(collapsible, taskListPanel); + batches.put(handle, batchUI); + batchesPanel.add(collapsible); + batchesPanel.add(Box.createVerticalStrut(4)); + + batchesPanel.revalidate(); + batchesPanel.repaint(); + }); + return handle; + } + + /** + * Remove all finished tasks, and any batches that become empty afterwards. + */ + public void clearFinishedTasksAndEmptyBatches() { + runOnEdt(() -> { + // Remove finished tasks from each batch + taskPanels.entrySet().removeIf(entry -> { + TaskPanel tp = entry.getValue(); + if (tp.isFinished()) { + Container parent = tp.getParent(); + if (parent != null) { + parent.remove(tp); + } + return true; + } + return false; + }); + + // Now remove empty batches + batches.entrySet().removeIf(entry -> { + BatchUI batchUI = entry.getValue(); + if (batchUI.taskListPanel.getComponentCount() == 0) { + batchesPanel.remove(batchUI.collapsible); + return true; + } + return false; + }); + + batchesPanel.revalidate(); + batchesPanel.repaint(); + }); + } + + // === Batch creation API internal helpers === + + private TaskHandle createTaskInBatch(BatchHandle batchHandle, String description, int min, int max, + Runnable onCancel) { + TaskHandle taskHandle = new TaskHandle(this, batchHandle); + + runOnEdt(() -> { + BatchUI batchUI = batches.get(batchHandle); + if (batchUI == null) { + return; // batch removed or not present + } + TaskPanel panel = new TaskPanel(taskHandle, description, min, max, onCancel); + taskPanels.put(taskHandle, panel); + batchUI.taskListPanel.add(panel); + batchUI.taskListPanel.revalidate(); + batchUI.taskListPanel.repaint(); + }); + + return taskHandle; + } + + private TaskHandle monitorSwingWorkerInBatch(BatchHandle batchHandle, final SwingWorker worker, + String description, boolean cancellable) { + Runnable onCancel = cancellable ? () -> worker.cancel(true) : null; + final TaskHandle handle = createTaskInBatch(batchHandle, description, 0, 100, onCancel); + + worker.addPropertyChangeListener(new PropertyChangeListener() { + @Override + public void propertyChange(PropertyChangeEvent evt) { + switch (evt.getPropertyName()) { + case "progress": + int value = (Integer) evt.getNewValue(); + handle.setProgress(value); + break; + case "state": + SwingWorker.StateValue state = (SwingWorker.StateValue) evt.getNewValue(); + if (state == SwingWorker.StateValue.DONE) { + handle.done(); + } + break; + default: + // ignore other properties + } + } + }); + + return handle; + } + + // === Internal helpers used by TaskHandle === + + private void updateProgress(TaskHandle handle, int value) { + runOnEdt(() -> { + TaskPanel panel = taskPanels.get(handle); + if (panel != null) { + panel.setProgress(value); + } + }); + } + + private void updateNote(TaskHandle handle, String note) { + runOnEdt(() -> { + TaskPanel panel = taskPanels.get(handle); + if (panel != null) { + panel.setNote(note); + } + }); + } + + private void markDone(TaskHandle handle) { + runOnEdt(() -> { + TaskPanel panel = taskPanels.get(handle); + if (panel != null) { + panel.setFinished(true); + } + }); + } + + private void markCancelled(TaskHandle handle) { + runOnEdt(() -> { + TaskPanel panel = taskPanels.get(handle); + if (panel != null) { + panel.setCancelled(); + } + }); + } + + /** + * Remove a single task row from the UI (used by the "Remove" button), and + * remove the batch if it becomes empty. + */ + private void removeTask(TaskHandle handle) { + runOnEdt(() -> { + TaskPanel panel = taskPanels.remove(handle); + if (panel != null) { + Container parent = panel.getParent(); + if (parent != null) { + parent.remove(panel); + parent.revalidate(); + parent.repaint(); + } + } + + // Remove batch if it has no tasks left + BatchUI batchUI = batches.get(handle.getBatch()); + if (batchUI != null && batchUI.taskListPanel.getComponentCount() == 0) { + batches.remove(handle.getBatch()); + batchesPanel.remove(batchUI.collapsible); + batchesPanel.revalidate(); + batchesPanel.repaint(); + } + }); + } + + void runOnEdt(Runnable r) { + if (SwingUtilities.isEventDispatchThread()) { + r.run(); + } else { + SwingUtilities.invokeLater(r); + } + } + + // === Public handles === + + /** + * Handle representing a batch (one CollapsiblePanel section). + */ + public static class BatchHandle { + private final BatchTaskProgressPanel owner; + + private BatchHandle(BatchTaskProgressPanel owner) { + this.owner = owner; + } + + /** + * Start a task in this batch + * + * @param description The description for the task + * @param min The minimum progress value + * @param max The maximum progress value + * @param onCancel The function to run on cancelling + * @return A handle to the task + */ + public TaskHandle startTask(String description, int min, int max, Runnable onCancel) { + return owner.createTaskInBatch(this, description, min, max, onCancel); + } + + /** + * Start a task in this batch + * + * @param description The description for the task + * @param min The minimum progress value + * @param max The maximum progress value + * @return A handle to the task + */ + public TaskHandle startTask(String description, int min, int max) { + return startTask(description, min, max, null); + } + + /** + * Start a task monitoring a given SwingWorker + * + * @param worker The SwingWorker to monitor + * @param description The description for the task + * @param cancellable Is the task cancellable? + * @return A handle to the task + */ + public TaskHandle monitorSwingWorker(SwingWorker worker, String description, boolean cancellable) { + return owner.monitorSwingWorkerInBatch(this, worker, description, cancellable); + } + + /** + * Set the header component for this batch + * @param comp + */ + public void setHeaderComponent(final Component comp) { + owner.runOnEdt(() -> { + BatchUI ui = owner.batches.get(this); + if (ui != null) { + ui.collapsible.setHeaderComponent(comp); + } + }); + } + } + + /** + * Handle that your worker code uses to update a specific task. + */ + public static class TaskHandle { + private final BatchTaskProgressPanel owner; + private final BatchHandle batch; + private volatile boolean cancelled = false; + private volatile boolean done = false; + + private TaskHandle(BatchTaskProgressPanel owner, BatchHandle batch) { + this.owner = owner; + this.batch = batch; + } + + /** + * Return the handle to the underlying batch + * @return The underlying batch + */ + public BatchHandle getBatch() { + return batch; + } + + /** + * Set the progress for the task + * @param value The progress value + */ + public void setProgress(int value) { + if (!done) { + owner.updateProgress(this, value); + } + } + + /** + * Set the note for the task + * @param note The note for the task + */ + public void setNote(String note) { + if (!done) { + owner.updateNote(this, note); + } + } + + /** + * Mark the task as done. + */ + public void done() { + done = true; + owner.markDone(this); + } + + /** + * Mark the task as cancelled. + */ + public void cancel() { + cancelled = true; + owner.markCancelled(this); + } + + /** + * Is the task cancelled? + * + * @return If the task is cancelled or not + */ + public boolean isCancelled() { + return cancelled; + } + + /** + * Is the task done? + * + * @return If the task is done or not + */ + public boolean isDone() { + return done; + } + + /** + * Called by the "Remove" button to drop this task row from the UI. + */ + public void dismiss() { + owner.removeTask(this); + } + } + + // === UI for a single task row === + + private static class TaskPanel extends JPanel { + private static final long serialVersionUID = 7163540519142582817L; + + private final TaskHandle handle; + private final JLabel descriptionLabel; + private final JLabel noteLabel; + private final JProgressBar progressBar; + private final JButton cancelButton; + private boolean finished = false; + + TaskPanel(TaskHandle handle, String description, int min, int max, Runnable onCancel) { + this.handle = handle; + setBorder(BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY), + BorderFactory.createEmptyBorder(4, 4, 4, 4))); + setLayout(new BorderLayout(4, 4)); + + descriptionLabel = new JLabel(description); + descriptionLabel.setFont(descriptionLabel.getFont().deriveFont(Font.BOLD)); + + noteLabel = new JLabel(" "); + noteLabel.setFont(noteLabel.getFont().deriveFont(Font.ITALIC, 11f)); + noteLabel.setForeground(Color.DARK_GRAY); + + progressBar = new JProgressBar(min, max); + progressBar.setStringPainted(true); + + JPanel textPanel = new JPanel(); + textPanel.setLayout(new BoxLayout(textPanel, BoxLayout.Y_AXIS)); + textPanel.add(descriptionLabel); + textPanel.add(Box.createVerticalStrut(2)); + textPanel.add(noteLabel); + + cancelButton = new JButton("Cancel"); + if (onCancel == null) { + // Non-cancellable: hide until it becomes a "Remove" button + cancelButton.setVisible(false); + } else { + cancelButton.addActionListener(e -> { + handle.cancel(); + onCancel.run(); + }); + } + + JPanel bottomPanel = new JPanel(new BorderLayout(4, 4)); + bottomPanel.add(progressBar, BorderLayout.CENTER); + bottomPanel.add(cancelButton, BorderLayout.EAST); + + add(textPanel, BorderLayout.CENTER); + add(bottomPanel, BorderLayout.SOUTH); + } + + void setProgress(int value) { + progressBar.setValue(value); + } + + void setNote(String note) { + noteLabel.setText((note == null || note.isEmpty()) ? " " : note); + } + + void setFinished(boolean finished) { + this.finished = finished; + progressBar.setIndeterminate(false); + progressBar.setValue(progressBar.getMaximum()); + noteLabel.setText("Completed"); + configureAsDismissButton(); + } + + void setCancelled() { + this.finished = true; + progressBar.setIndeterminate(false); + noteLabel.setText("Cancelled"); + progressBar.setForeground(Color.GRAY); + configureAsDismissButton(); + } + + boolean isFinished() { + return finished; + } + + /** + * Re-purpose the Cancel button as a "Remove" button that removes this task row. + */ + private void configureAsDismissButton() { + // Ensure the button is visible + cancelButton.setVisible(true); + + // Remove existing listeners (Cancel behavior) + for (ActionListener l : cancelButton.getActionListeners()) { + cancelButton.removeActionListener(l); + } + + cancelButton.setText("Remove"); + cancelButton.setEnabled(true); + cancelButton.addActionListener(e -> handle.dismiss()); + } + } +} -- cgit v1.2.3