Skip to content

Commit 0be3112

Browse files
authored
feat: [TBB] extract SpinnerAnimator for loading animations in AgentStatusLabel (Thinking Part1) (#146)
1 parent 86d147a commit 0be3112

2 files changed

Lines changed: 109 additions & 59 deletions

File tree

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java

Lines changed: 9 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,26 @@
1010
import org.eclipse.swt.layout.GridData;
1111
import org.eclipse.swt.layout.GridLayout;
1212
import org.eclipse.swt.widgets.Composite;
13-
import org.eclipse.swt.widgets.Display;
1413
import org.eclipse.swt.widgets.Label;
1514
import org.eclipse.ui.ISharedImages;
1615
import org.eclipse.ui.PlatformUI;
1716
import org.osgi.service.event.EventHandler;
1817

1918
import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
2019
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
20+
import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator;
2121
import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils;
2222
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
2323

2424
/**
2525
* A label with icon that displays the running status of the agent.
2626
*/
2727
public class AgentStatusLabel extends Composite {
28-
private static final int TOTAL_FRAMES = 8; // Adjust based on actual number of spinner images
29-
30-
private Image runningIcon;
3128
private Image completedIcon;
3229
private Image cancelledIcon;
3330
private Label iconLabel;
3431
private ChatMarkupViewer textLabel;
35-
private int currentFrame = 1;
36-
private Runnable animationRunnable;
32+
private SpinnerAnimator spinner;
3733
private Status status;
3834
private EventHandler cancelStatusHandler;
3935
private IEventBroker eventBroker;
@@ -47,14 +43,12 @@ public class AgentStatusLabel extends Composite {
4743
public AgentStatusLabel(Composite parent, int style) {
4844
super(parent, style);
4945
GridLayout layout = new GridLayout(2, false);
46+
layout.marginWidth = 0;
47+
layout.marginHeight = 0;
5048
layout.horizontalSpacing = 0;
5149
setLayout(layout);
5250
setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
5351
this.addDisposeListener(e -> {
54-
stopAnimation();
55-
if (this.runningIcon != null && !this.runningIcon.isDisposed()) {
56-
this.runningIcon.dispose();
57-
}
5852
if (this.completedIcon != null && !this.completedIcon.isDisposed()) {
5953
this.completedIcon.dispose();
6054
}
@@ -66,6 +60,7 @@ public AgentStatusLabel(Composite parent, int style) {
6660
}
6761
});
6862
iconLabel = new Label(this, SWT.LEFT);
63+
spinner = new SpinnerAnimator(iconLabel);
6964

7065
this.status = Status.RUNNING;
7166
this.cancelStatusHandler = new EventHandler() {
@@ -84,7 +79,7 @@ public void handleEvent(org.osgi.service.event.Event event) {
8479
* @param statusText the text to display when the agent is completed
8580
*/
8681
public void setCompletedStatus(String statusText) {
87-
stopAnimation();
82+
spinner.stop();
8883

8984
if (this.completedIcon == null) {
9085
this.completedIcon = UiUtils.buildImageFromPngPath("/icons/complete_status.png");
@@ -101,11 +96,7 @@ public void setCompletedStatus(String statusText) {
10196
* @param statusText the text to display when the agent is running
10297
*/
10398
public void setRunningStatus(String statusText) {
104-
// Stop any existing animation
105-
stopAnimation();
106-
107-
// Start new animation
108-
startAnimation();
99+
spinner.start();
109100

110101
setText(statusText);
111102
this.status = Status.RUNNING;
@@ -116,7 +107,7 @@ public void setRunningStatus(String statusText) {
116107
*/
117108
public void setErrorStatus() {
118109
if (this.status == Status.RUNNING) {
119-
stopAnimation();
110+
spinner.stop();
120111
}
121112
iconLabel.setImage(PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_ERROR_TSK));
122113
this.status = Status.ERROR;
@@ -127,7 +118,7 @@ public void setErrorStatus() {
127118
*/
128119
public void setCancelledStatus() {
129120
if (this.status == Status.RUNNING) {
130-
stopAnimation();
121+
spinner.stop();
131122

132123
if (this.cancelledIcon == null) {
133124
this.cancelledIcon = UiUtils.buildImageFromPngPath("/icons/cancel_status.png");
@@ -138,47 +129,6 @@ public void setCancelledStatus() {
138129
}
139130
}
140131

141-
private void startAnimation() {
142-
final Display display = getDisplay();
143-
144-
animationRunnable = new Runnable() {
145-
@Override
146-
public void run() {
147-
if (isDisposed() || iconLabel.isDisposed()) {
148-
return;
149-
}
150-
151-
// Dispose previous image
152-
if (runningIcon != null && !runningIcon.isDisposed()) {
153-
runningIcon.dispose();
154-
}
155-
156-
// Load the next frame
157-
String imagePath = String.format("/icons/spinner/%d.png", currentFrame);
158-
runningIcon = UiUtils.buildImageFromPngPath(imagePath);
159-
iconLabel.setImage(runningIcon);
160-
// request layout to update the icon, otherwise the scale of the spinner will be wrong
161-
iconLabel.requestLayout();
162-
163-
// Update frame counter
164-
currentFrame = (currentFrame % TOTAL_FRAMES) + 1;
165-
166-
// Schedule next frame
167-
display.timerExec(100, this);
168-
}
169-
};
170-
171-
// Start the animation
172-
display.timerExec(0, animationRunnable);
173-
}
174-
175-
private void stopAnimation() {
176-
if (animationRunnable != null) {
177-
getDisplay().timerExec(-1, animationRunnable);
178-
animationRunnable = null;
179-
}
180-
}
181-
182132
/**
183133
* Set the text to display next to the icon.
184134
*/
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package com.microsoft.copilot.eclipse.ui.swt;
5+
6+
import org.eclipse.swt.graphics.Image;
7+
import org.eclipse.swt.widgets.Display;
8+
import org.eclipse.swt.widgets.Label;
9+
10+
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
11+
12+
/**
13+
* Drives a rotating spinner animation on a target {@link Label}.
14+
*
15+
* <p>The animator owns the lifecycle of the per-frame {@link Image} resources: each new frame is
16+
* loaded, the previous one is disposed, and {@link #stop()} guarantees that the label no longer
17+
* holds a reference to a disposed image. After {@link #stop()} the caller is free to swap in a
18+
* final image (e.g. a "completed" icon) on the same label.
19+
*
20+
* <p>The animator hooks the target label's dispose listener so the animation is cancelled and the
21+
* running frame is freed automatically when the label goes away.
22+
*/
23+
public final class SpinnerAnimator {
24+
/** Total number of frames in the spinner animation under {@code /icons/spinner/}. */
25+
private static final int TOTAL_FRAMES = 8;
26+
/** Per-frame interval in milliseconds. */
27+
private static final int FRAME_INTERVAL_MS = 100;
28+
29+
private final Label target;
30+
private Image currentFrameImage;
31+
private int currentFrame = 1;
32+
private Runnable animationRunnable;
33+
34+
/**
35+
* Create an animator that will rotate spinner frames on the given label.
36+
*
37+
* @param target the label to update with each frame; must not be {@code null}
38+
*/
39+
public SpinnerAnimator(Label target) {
40+
this.target = target;
41+
target.addDisposeListener(e -> stop());
42+
}
43+
44+
/**
45+
* Start (or restart) the animation. Safe to call when already running — the existing animation
46+
* is cancelled first.
47+
*/
48+
public void start() {
49+
if (target.isDisposed()) {
50+
return;
51+
}
52+
stop();
53+
currentFrame = 1;
54+
final Display display = target.getDisplay();
55+
animationRunnable = new Runnable() {
56+
@Override
57+
public void run() {
58+
if (target.isDisposed()) {
59+
return;
60+
}
61+
// Dispose the previous frame before loading the next one.
62+
if (currentFrameImage != null && !currentFrameImage.isDisposed()) {
63+
currentFrameImage.dispose();
64+
}
65+
currentFrameImage = buildFrame(currentFrame);
66+
target.setImage(currentFrameImage);
67+
// Request layout so the icon scale stays correct as frames change.
68+
target.requestLayout();
69+
currentFrame = (currentFrame % TOTAL_FRAMES) + 1;
70+
display.timerExec(FRAME_INTERVAL_MS, this);
71+
}
72+
};
73+
display.timerExec(0, animationRunnable);
74+
}
75+
76+
/**
77+
* Stop the animation and release the frame image. Detaches the image from the target label
78+
* before disposing it so the label never points at a disposed image. Safe to call repeatedly.
79+
*/
80+
public void stop() {
81+
if (animationRunnable != null && !target.isDisposed()) {
82+
target.getDisplay().timerExec(-1, animationRunnable);
83+
}
84+
animationRunnable = null;
85+
// Detach the image from the label before disposing it so the label never points at a
86+
// disposed image. Callers that want a final icon (completed/cancelled/error) set it
87+
// immediately after stop(), avoiding any visible flicker.
88+
if (!target.isDisposed() && target.getImage() == currentFrameImage) {
89+
target.setImage(null);
90+
}
91+
if (currentFrameImage != null && !currentFrameImage.isDisposed()) {
92+
currentFrameImage.dispose();
93+
}
94+
currentFrameImage = null;
95+
}
96+
97+
private static Image buildFrame(int frame) {
98+
return UiUtils.buildImageFromPngPath(String.format("/icons/spinner/%d.png", frame));
99+
}
100+
}

0 commit comments

Comments
 (0)