I’m creating a hover-preview popup in a GNOME Shell extension.
The preview consists of a Clutter.Clone (cloning a window actor) placed inside a St.BoxLayout wrapper.
Here is the code
_showHoverPreview() {
if (!this._window || this._hoverPreview) return;
const windowActor = this._window.get_compositor_private();
if (!windowActor) return;
const windowPreviewWidth = this.get_width();
const [windowPreviewX, windowPreviewY] = this.get_transformed_position();
const windowActorWidth = windowActor.width;
const windowActorHeight = windowActor.height;
const windowActorAspectRatio = windowActorWidth / windowActorHeight;
const previewHeight = 800;
const previewWidth = previewHeight * windowActorAspectRatio;
let previewX = windowPreviewX + (windowPreviewWidth - previewWidth) / 2;
const previewY = windowPreviewY - previewHeight - 40;
previewX = Math.max(0, previewX);
// Create the clone
const clone = new Clutter.Clone({
source: windowActor,
width: previewWidth,
height: previewHeight,
reactive: false,
});
// Create wrapper with hover tracking
const wrapper = new St.BoxLayout({
style_class: 'hover-preview-wrapper',
x: previewX,
y: previewY,
width: previewWidth,
height: previewHeight,
reactive: true,
track_hover: true, // Track hover on preview too
});
// Connect preview's hover signal
wrapper.connect('notify::hover', () => {
if (!wrapper.hover && !this.hover) {
// Neither button nor preview is hovered - hide the preview
this._hideHoverPreview();
}
});
// Add click handler
wrapper.connect('button-press-event', (actor, event) => {
if (event.get_button() === Clutter.BUTTON_PRIMARY) {
let win_workspace = this._window.get_workspace();
win_workspace.activate_with_focus(this._window, 0);
this._hideHoverPreview();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
// Pack clone inside wrapper
wrapper.add_child(clone);
wrapper.set_width(clone.width);
wrapper.set_height(clone.height);
journal(`windowActorAspectRatio: ${windowActorAspectRatio}`);
journal(`Wrapper width: ${wrapper.width}`);
journal(`Wrapper height: ${wrapper.height}`);
journal(`Clone width: ${clone.width}`);
journal(`Clone height: ${clone.height}`);
this._hoverPreview = wrapper;
this._hoverPreview.opacity = 0;
Main.layoutManager.addChrome(this._hoverPreview);
this._hoverPreview.ease({
opacity: 255,
duration: 600,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
Here is the css:
.hover-preview-wrapper {
background-color: #268bd2;
}
If the original window is not fullscreen, the clone ends up visibly smaller than the wrapper, so I see the wrapper’s background color around all four sides.
What I expected was that the clone should scale to fill the wrapper dimensions exactly.
Why does Clutter.Clone ignore the wrapper’s size and render smaller, even though I’m explicitly setting its width and height?
And what is the correct way to make the clone fill the wrapper without showing background gaps?
skeller
(Sebastian Keller)
November 29, 2025, 2:08pm
2
Windows have shadows when they are not fullscreen. These window shadows are part of the actor. get_frame_rect() on the underlying MetaWindow gives you the MtkRectangle corresponding to the window without the shadow.
1 Like
// const windowActorWidth = windowActor.width;
// const windowActorHeight = windowActor.height;
const windowFrame = this._window.get_frame_rect();
const windowActorWidth = windowFrame.width;
const windowActorHeight = windowFrame.height;
I have added the following lines. Still having the same issue.
skeller
(Sebastian Keller)
November 29, 2025, 2:39pm
4
That would just scale the window down, while keeping the shadows. You could set the container to clip_to_allocation and calculate the the size and placement of the window within that using the frame rect such that the shadows would be placed outside of the container.
1 Like
I am just looking at this for ~ last one hour. not understanding how to clip_to_allocation and calculate the the size and placement of the window within that using the frame rect such that the shadows would be placed outside of the container.
Can you please give me some code sample.
The following code works. But I think it became very complicated. Can it be simplified?
_showHoverPreview() {
if (!this._window || this._hoverPreview) return;
const windowActor = this._window.get_compositor_private();
if (!windowActor) return;
const windowPreviewWidth = this.get_width();
const [windowPreviewX, windowPreviewY] = this.get_transformed_position();
const windowFrame = this._window.get_frame_rect();
const bufferFrame = this._window.get_buffer_rect();
const windowActorWidth = windowFrame.width;
const windowActorHeight = windowFrame.height;
const windowActorAspectRatio = windowActorWidth / windowActorHeight;
const previewHeight = 800;
const previewWidth = previewHeight * windowActorAspectRatio;
let previewX = windowPreviewX + (windowPreviewWidth - previewWidth) / 2;
const previewY = windowPreviewY - previewHeight - 40;
previewX = Math.max(0, previewX);
journal(`previewX: ${previewX}`);
journal(`previewY: ${previewY}`);
//
// OUTER WRAPPER (shows border)
//
const outerWrapper = new St.BoxLayout({
style_class: 'hover-preview-wrapper',
x: previewX,
y: previewY,
reactive: true,
track_hover: true,
});
//
// INNER CONTAINER (clips clone content)
//
const innerContainer = new St.BoxLayout({
style_class: 'hover-preview-inner',
width: previewWidth,
height: previewHeight,
clip_to_allocation: true,
});
outerWrapper.add_child(innerContainer);
// Hover logic
outerWrapper.connect('notify::hover', () => {
if (!outerWrapper.hover && !this.hover) {
this._hideHoverPreview();
}
});
// Click → activate window
outerWrapper.connect('button-press-event', (actor, event) => {
if (event.get_button() === Clutter.BUTTON_PRIMARY) {
let win_workspace = this._window.get_workspace();
win_workspace.activate_with_focus(this._window, 0);
this._hideHoverPreview();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
//
// SHADOW CROPPING LOGIC
//
const frame = windowFrame;
const buffer = bufferFrame;
const leftShadow = frame.x - buffer.x;
const topShadow = frame.y - buffer.y;
const rightShadow = (buffer.x + buffer.width) - (frame.x + frame.width);
const bottomShadow = (buffer.y + buffer.height) - (frame.y + frame.height);
// Actor that can shift the clone
const cloneContainer = new Clutter.Actor({
width: previewWidth,
height: previewHeight,
});
const clone = new Clutter.Clone({
source: windowActor,
width: previewWidth + leftShadow + rightShadow,
height: previewHeight + topShadow + bottomShadow,
});
clone.set_position(-leftShadow, -topShadow);
cloneContainer.add_child(clone);
innerContainer.add_child(cloneContainer);
this._hoverPreview = outerWrapper;
this._hoverPreview.opacity = 0;
Main.layoutManager.addChrome(this._hoverPreview);
this._hoverPreview.ease({
opacity: 255,
duration: 600,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}