From eecff3f8eb2095e142d076d9365e987c2e8fa9ac Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 7 May 2026 20:54:48 +0800 Subject: [PATCH 1/2] feat: extract SpinnerAnimator for loading animations in AgentStatusLabel --- .../eclipse/ui/chat/AgentStatusLabel.java | 68 ++----------- .../eclipse/ui/swt/SpinnerAnimator.java | 99 +++++++++++++++++++ 2 files changed, 108 insertions(+), 59 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java index 039931b3..4979bfff 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java @@ -10,7 +10,6 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; -import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -18,6 +17,7 @@ import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -25,15 +25,11 @@ * A label with icon that displays the running status of the agent. */ public class AgentStatusLabel extends Composite { - private static final int TOTAL_FRAMES = 8; // Adjust based on actual number of spinner images - - private Image runningIcon; private Image completedIcon; private Image cancelledIcon; private Label iconLabel; private ChatMarkupViewer textLabel; - private int currentFrame = 1; - private Runnable animationRunnable; + private SpinnerAnimator spinner; private Status status; private EventHandler cancelStatusHandler; private IEventBroker eventBroker; @@ -47,14 +43,12 @@ public class AgentStatusLabel extends Composite { public AgentStatusLabel(Composite parent, int style) { super(parent, style); GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 0; layout.horizontalSpacing = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); this.addDisposeListener(e -> { - stopAnimation(); - if (this.runningIcon != null && !this.runningIcon.isDisposed()) { - this.runningIcon.dispose(); - } if (this.completedIcon != null && !this.completedIcon.isDisposed()) { this.completedIcon.dispose(); } @@ -66,6 +60,7 @@ public AgentStatusLabel(Composite parent, int style) { } }); iconLabel = new Label(this, SWT.LEFT); + spinner = new SpinnerAnimator(iconLabel); this.status = Status.RUNNING; this.cancelStatusHandler = new EventHandler() { @@ -84,7 +79,7 @@ public void handleEvent(org.osgi.service.event.Event event) { * @param statusText the text to display when the agent is completed */ public void setCompletedStatus(String statusText) { - stopAnimation(); + spinner.stop(); if (this.completedIcon == null) { this.completedIcon = UiUtils.buildImageFromPngPath("/icons/complete_status.png"); @@ -101,11 +96,7 @@ public void setCompletedStatus(String statusText) { * @param statusText the text to display when the agent is running */ public void setRunningStatus(String statusText) { - // Stop any existing animation - stopAnimation(); - - // Start new animation - startAnimation(); + spinner.start(); setText(statusText); this.status = Status.RUNNING; @@ -116,7 +107,7 @@ public void setRunningStatus(String statusText) { */ public void setErrorStatus() { if (this.status == Status.RUNNING) { - stopAnimation(); + spinner.stop(); } iconLabel.setImage(PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_ERROR_TSK)); this.status = Status.ERROR; @@ -127,7 +118,7 @@ public void setErrorStatus() { */ public void setCancelledStatus() { if (this.status == Status.RUNNING) { - stopAnimation(); + spinner.stop(); if (this.cancelledIcon == null) { this.cancelledIcon = UiUtils.buildImageFromPngPath("/icons/cancel_status.png"); @@ -138,47 +129,6 @@ public void setCancelledStatus() { } } - private void startAnimation() { - final Display display = getDisplay(); - - animationRunnable = new Runnable() { - @Override - public void run() { - if (isDisposed() || iconLabel.isDisposed()) { - return; - } - - // Dispose previous image - if (runningIcon != null && !runningIcon.isDisposed()) { - runningIcon.dispose(); - } - - // Load the next frame - String imagePath = String.format("/icons/spinner/%d.png", currentFrame); - runningIcon = UiUtils.buildImageFromPngPath(imagePath); - iconLabel.setImage(runningIcon); - // request layout to update the icon, otherwise the scale of the spinner will be wrong - iconLabel.requestLayout(); - - // Update frame counter - currentFrame = (currentFrame % TOTAL_FRAMES) + 1; - - // Schedule next frame - display.timerExec(100, this); - } - }; - - // Start the animation - display.timerExec(0, animationRunnable); - } - - private void stopAnimation() { - if (animationRunnable != null) { - getDisplay().timerExec(-1, animationRunnable); - animationRunnable = null; - } - } - /** * Set the text to display next to the icon. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java new file mode 100644 index 00000000..84698ff3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.swt; + +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Drives a rotating spinner animation on a target {@link Label}. + * + *

The animator owns the lifecycle of the per-frame {@link Image} resources: each new frame is + * loaded, the previous one is disposed, and {@link #stop()} guarantees that the label no longer + * holds a reference to a disposed image. After {@link #stop()} the caller is free to swap in a + * final image (e.g. a "completed" icon) on the same label. + * + *

The animator hooks the target label's dispose listener so the animation is cancelled and the + * running frame is freed automatically when the label goes away. + */ +public final class SpinnerAnimator { + /** Total number of frames in the spinner animation under {@code /icons/spinner/}. */ + private static final int TOTAL_FRAMES = 8; + /** Per-frame interval in milliseconds. */ + private static final int FRAME_INTERVAL_MS = 100; + + private final Label target; + private Image currentFrameImage; + private int currentFrame = 1; + private Runnable animationRunnable; + + /** + * Create an animator that will rotate spinner frames on the given label. + * + * @param target the label to update with each frame; must not be {@code null} + */ + public SpinnerAnimator(Label target) { + this.target = target; + target.addDisposeListener(e -> stop()); + } + + /** + * Start (or restart) the animation. Safe to call when already running — the existing animation + * is cancelled first. + */ + public void start() { + if (target.isDisposed()) { + return; + } + stop(); + final Display display = target.getDisplay(); + animationRunnable = new Runnable() { + @Override + public void run() { + if (target.isDisposed()) { + return; + } + // Dispose the previous frame before loading the next one. + if (currentFrameImage != null && !currentFrameImage.isDisposed()) { + currentFrameImage.dispose(); + } + currentFrameImage = buildFrame(currentFrame); + target.setImage(currentFrameImage); + // Request layout so the icon scale stays correct as frames change. + target.requestLayout(); + currentFrame = (currentFrame % TOTAL_FRAMES) + 1; + display.timerExec(FRAME_INTERVAL_MS, this); + } + }; + display.timerExec(0, animationRunnable); + } + + /** + * Stop the animation and release the frame image. Detaches the image from the target label + * before disposing it so the label never points at a disposed image. Safe to call repeatedly. + */ + public void stop() { + if (animationRunnable != null && !target.isDisposed()) { + target.getDisplay().timerExec(-1, animationRunnable); + } + animationRunnable = null; + // Detach the image from the label before disposing it so the label never points at a + // disposed image. Callers that want a final icon (completed/cancelled/error) set it + // immediately after stop(), avoiding any visible flicker. + if (!target.isDisposed() && target.getImage() == currentFrameImage) { + target.setImage(null); + } + if (currentFrameImage != null && !currentFrameImage.isDisposed()) { + currentFrameImage.dispose(); + } + currentFrameImage = null; + } + + private static Image buildFrame(int frame) { + return UiUtils.buildImageFromPngPath(String.format("/icons/spinner/%d.png", frame)); + } +} From 08488e6a01ca2ffe73a3385e177ac123a5a4b388 Mon Sep 17 00:00:00 2001 From: Ethan Hou <149548697+ethanyhou@users.noreply.github.com> Date: Thu, 7 May 2026 21:03:59 +0800 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java | 1 + 1 file changed, 1 insertion(+) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java index 84698ff3..7ebafdb5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java @@ -50,6 +50,7 @@ public void start() { return; } stop(); + currentFrame = 1; final Display display = target.getDisplay(); animationRunnable = new Runnable() { @Override