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..7ebafdb5 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java @@ -0,0 +1,100 @@ +// 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(); + currentFrame = 1; + 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)); + } +}