Skip to content

Commit 613f7fe

Browse files
committed
refactor: Implement BaseHoverPopup for ContextWindowPopup
1 parent 7bf4cd0 commit 613f7fe

2 files changed

Lines changed: 338 additions & 247 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package com.microsoft.copilot.eclipse.ui.chat;
2+
3+
import org.eclipse.e4.ui.services.IStylingEngine;
4+
import org.eclipse.swt.SWT;
5+
import org.eclipse.swt.graphics.Color;
6+
import org.eclipse.swt.graphics.Font;
7+
import org.eclipse.swt.graphics.Point;
8+
import org.eclipse.swt.graphics.Rectangle;
9+
import org.eclipse.swt.layout.GridData;
10+
import org.eclipse.swt.layout.GridLayout;
11+
import org.eclipse.swt.widgets.Composite;
12+
import org.eclipse.swt.widgets.Control;
13+
import org.eclipse.swt.widgets.Display;
14+
import org.eclipse.swt.widgets.Label;
15+
import org.eclipse.swt.widgets.Monitor;
16+
import org.eclipse.swt.widgets.Shell;
17+
import org.eclipse.ui.PlatformUI;
18+
19+
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
20+
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
21+
22+
/**
23+
* Base class for hover popups that appear near an anchor widget and auto-close when the cursor leaves.
24+
*
25+
* <p>
26+
* Subclasses implement {@link #populateContent(Composite)} to add their specific widgets, then call
27+
* {@link #openPopup(Control)} to display the popup.
28+
*/
29+
public abstract class BaseHoverPopup {
30+
31+
protected static final String DROPDOWN_POPUP_CSS_ID = "dropdown-popup";
32+
protected static final int SECTION_SPACING = 8;
33+
34+
private static final String POPUP_SECONDARY_TEXT_CLASS = "popup-secondary-text";
35+
private static final int POPUP_WIDTH = 200;
36+
private static final int ROW_SPACING = 2;
37+
private static final int BORDER_ARC = 8;
38+
private static final int H_MARGIN = 8;
39+
private static final int V_MARGIN = 8;
40+
private static final int POLL_INTERVAL_MS = 100;
41+
42+
protected Shell shell;
43+
protected Control anchor;
44+
protected final IStylingEngine stylingEngine;
45+
private Runnable pollRunnable;
46+
47+
/**
48+
* Constructor initializes the styling engine for applying CSS styles to popup widgets.
49+
*/
50+
protected BaseHoverPopup() {
51+
this.stylingEngine = PlatformUI.getWorkbench().getService(IStylingEngine.class);
52+
}
53+
54+
/**
55+
* Creates the popup shell, calls {@link #populateContent(Composite)}, and opens the popup above the anchor.
56+
*/
57+
protected void openPopup(Control anchorControl) {
58+
this.anchor = anchorControl;
59+
if (isOpen()) {
60+
return;
61+
}
62+
stopPolling();
63+
64+
Shell parentShell = anchorControl.getShell();
65+
shell = new Shell(parentShell, SWT.NO_TRIM | SWT.ON_TOP);
66+
67+
GridLayout shellLayout = new GridLayout(1, false);
68+
shellLayout.marginWidth = H_MARGIN;
69+
shellLayout.marginHeight = V_MARGIN;
70+
shellLayout.verticalSpacing = ROW_SPACING;
71+
shell.setLayout(shellLayout);
72+
73+
populateContent(shell);
74+
applyCssId(shell, DROPDOWN_POPUP_CSS_ID);
75+
addBorder(shell);
76+
77+
shell.pack();
78+
Point size = shell.getSize();
79+
shell.setSize(Math.max(size.x, POPUP_WIDTH), size.y);
80+
positionPopup(shell, anchorControl);
81+
shell.open();
82+
startPolling();
83+
}
84+
85+
/**
86+
* Subclasses add their specific content widgets to the popup shell.
87+
*/
88+
protected abstract void populateContent(Composite parent);
89+
90+
/**
91+
* Closes the popup and disposes the shell. Should be called when the cursor leaves the popup or anchor.
92+
*/
93+
protected void close() {
94+
stopPolling();
95+
anchor = null;
96+
if (shell != null && !shell.isDisposed()) {
97+
shell.dispose();
98+
shell = null;
99+
}
100+
}
101+
102+
protected boolean isOpen() {
103+
return shell != null && !shell.isDisposed();
104+
}
105+
106+
/**
107+
* Utility method for adding a section header label with bold font and vertical spacing.
108+
*
109+
* @param parent the parent composite to add the header to
110+
* @param text the header text to display
111+
* @param verticalIndent the vertical spacing below the header
112+
*/
113+
protected void addSectionHeader(Composite parent, String text, int verticalIndent) {
114+
Label header = new Label(parent, SWT.NONE);
115+
header.setText(text);
116+
applyCssId(header, DROPDOWN_POPUP_CSS_ID);
117+
Font boldFont = UiUtils.getBoldFont(header.getDisplay(), header.getFont());
118+
header.addDisposeListener(e -> boldFont.dispose());
119+
header.setFont(boldFont);
120+
GridData gd = new GridData(SWT.FILL, SWT.NONE, true, false);
121+
gd.verticalIndent = verticalIndent;
122+
header.setLayoutData(gd);
123+
}
124+
125+
/**
126+
* Utility method for adding a horizontal separator with optional top spacing.
127+
*
128+
* @param parent the parent composite to add the separator to
129+
* @param topSpacing the vertical spacing above the separator
130+
*/
131+
protected void addSeparator(Composite parent, int topSpacing) {
132+
Composite separator = new Composite(parent, SWT.NONE);
133+
applyCssId(separator, DROPDOWN_POPUP_CSS_ID);
134+
GridData gd = new GridData(SWT.FILL, SWT.NONE, true, false);
135+
gd.heightHint = 1;
136+
gd.verticalIndent = topSpacing;
137+
separator.setLayoutData(gd);
138+
Color sepColor = CssConstants.getSeparatorColor(parent.getDisplay());
139+
separator.addPaintListener(e -> {
140+
Rectangle r = separator.getClientArea();
141+
e.gc.setBackground(sepColor);
142+
e.gc.fillRectangle(0, 0, r.width, 1);
143+
});
144+
}
145+
146+
/**
147+
* Utility method for creating a row composite with two columns for key-value pairs, styled for the popup.
148+
*
149+
* @param parent the parent composite to add the row to
150+
* @return the created row composite with a GridLayout of 2 columns and no margins or spacing
151+
*/
152+
protected Composite createRowComposite(Composite parent) {
153+
Composite row = new Composite(parent, SWT.NONE);
154+
applyCssId(row, DROPDOWN_POPUP_CSS_ID);
155+
GridLayout gl = new GridLayout(2, false);
156+
gl.marginWidth = 0;
157+
gl.marginHeight = 0;
158+
gl.horizontalSpacing = 0;
159+
row.setLayout(gl);
160+
row.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false));
161+
return row;
162+
}
163+
164+
/**
165+
* Utility method for adding a key-value pair row to the popup, with the key left-aligned and the value right-aligned.
166+
*
167+
* @param parent the parent composite to add the row to
168+
* @param key the text for the key label, which will be left-aligned and take up remaining horizontal space
169+
* @param value the text for the value label, which will be right-aligned and take only necessary horizontal space
170+
* @return the value label, in case the caller wants to update its text later
171+
*/
172+
protected Label addKeyValueRow(Composite parent, String key, String value) {
173+
Composite row = createRowComposite(parent);
174+
175+
Label keyLabel = createSecondaryTextLabel(row, key);
176+
keyLabel.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false));
177+
178+
Label valueLabel = createSecondaryTextLabel(row, value);
179+
valueLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, false, false));
180+
return valueLabel;
181+
}
182+
183+
/**
184+
* Utility method for creating a label styled as secondary text in the popup, with the appropriate CSS class and ID.
185+
*
186+
* @param parent the parent composite to add the label to
187+
* @param text the text to display in the label
188+
* @return the created label with the secondary text style applied
189+
*/
190+
protected Label createSecondaryTextLabel(Composite parent, String text) {
191+
Label label = new Label(parent, SWT.NONE);
192+
label.setText(text);
193+
applyCssId(label, DROPDOWN_POPUP_CSS_ID);
194+
UiUtils.applyCssClass(label, POPUP_SECONDARY_TEXT_CLASS, stylingEngine);
195+
return label;
196+
}
197+
198+
/**
199+
* Utility method for applying a CSS ID to a control and re-styling it with the styling engine.
200+
*
201+
* @param control the control to apply the CSS ID to
202+
* @param cssId the CSS ID to set on the control, which can be used in CSS selectors to style it
203+
*/
204+
protected void applyCssId(Control control, String cssId) {
205+
control.setData(CssConstants.CSS_ID_KEY, cssId);
206+
if (stylingEngine != null) {
207+
stylingEngine.style(control);
208+
}
209+
}
210+
211+
private void addBorder(Shell target) {
212+
Color borderColor = CssConstants.getBorderColor(target.getDisplay());
213+
target.addPaintListener(e -> {
214+
Rectangle bounds = target.getClientArea();
215+
e.gc.setAntialias(SWT.ON);
216+
e.gc.setForeground(borderColor);
217+
e.gc.setLineWidth(1);
218+
e.gc.drawRoundRectangle(0, 0, bounds.width - 1, bounds.height - 1, BORDER_ARC, BORDER_ARC);
219+
});
220+
}
221+
222+
private void positionPopup(Shell popup, Control anchorControl) {
223+
Point anchorLoc = anchorControl.toDisplay(0, 0);
224+
Point anchorSize = anchorControl.getSize();
225+
Point popupSize = popup.getSize();
226+
227+
int x = anchorLoc.x + (anchorSize.x - popupSize.x) / 2;
228+
int y = anchorLoc.y - popupSize.y;
229+
230+
Rectangle screenBounds = getMonitorBounds(anchorControl.getDisplay(), anchorLoc);
231+
x = Math.max(screenBounds.x, Math.min(x, screenBounds.x + screenBounds.width - popupSize.x));
232+
if (y < screenBounds.y) {
233+
y = anchorLoc.y + anchorSize.y;
234+
}
235+
popup.setLocation(x, y);
236+
}
237+
238+
private static Rectangle getMonitorBounds(Display display, Point location) {
239+
for (Monitor monitor : display.getMonitors()) {
240+
if (monitor.getBounds().contains(location)) {
241+
return monitor.getBounds();
242+
}
243+
}
244+
return display.getPrimaryMonitor().getBounds();
245+
}
246+
247+
private void startPolling() {
248+
Display display = getActiveDisplay();
249+
if (display == null) {
250+
return;
251+
}
252+
pollRunnable = () -> {
253+
if (shell == null || shell.isDisposed()) {
254+
return;
255+
}
256+
if (!isCursorInside(anchor) && !isCursorInside(shell)) {
257+
close();
258+
} else {
259+
display.timerExec(POLL_INTERVAL_MS, pollRunnable);
260+
}
261+
};
262+
display.timerExec(POLL_INTERVAL_MS, pollRunnable);
263+
}
264+
265+
private void stopPolling() {
266+
Display display = getActiveDisplay();
267+
if (display != null && pollRunnable != null) {
268+
display.timerExec(-1, pollRunnable);
269+
}
270+
pollRunnable = null;
271+
}
272+
273+
private Display getActiveDisplay() {
274+
if (shell != null && !shell.isDisposed()) {
275+
return shell.getDisplay();
276+
}
277+
if (anchor != null && !anchor.isDisposed()) {
278+
return anchor.getDisplay();
279+
}
280+
return null;
281+
}
282+
283+
private boolean isCursorInside(Control control) {
284+
if (control == null || control.isDisposed()) {
285+
return false;
286+
}
287+
Display display = control.getDisplay();
288+
if (display == null || display.isDisposed()) {
289+
return false;
290+
}
291+
return getDisplayBounds(control).contains(display.getCursorLocation());
292+
}
293+
294+
private static Rectangle getDisplayBounds(Control control) {
295+
Point location = control.toDisplay(0, 0);
296+
Point size = control.getSize();
297+
return new Rectangle(location.x, location.y, size.x, size.y);
298+
}
299+
}

0 commit comments

Comments
 (0)